diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..d47a8415b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Proto sources are sha256-hashed by the build.rs descriptor-freshness guards +# (waproto, sqlite-storage wire, wacore voip tables). Force LF so a CRLF checkout +# (Windows core.autocrlf=true) can't change the hash and trip a false "stale +# descriptor" build error. The committed descriptors are binary. +*.proto text eol=lf +*.desc binary +*.desc.sha256 text eol=lf diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 23f8b690c..6f2417190 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -77,6 +77,12 @@ jobs: run: cargo build --workspace --exclude e2e-tests --all-targets --verbose - name: Run tests run: cargo test --workspace --exclude e2e-tests --verbose -- --nocapture + # waproto serde tests are feature-gated and produce zero tests under the + # default feature set, so exercise each representation explicitly. + - name: Test waproto serde (enum repr) + run: cargo test -p waproto --features serde-enum-repr --verbose + - name: Test waproto serde (snake_case) + run: cargo test -p waproto --features serde-snake-case --verbose test-all-features: name: Build & Lint (all features) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ee8580ae..1fda01922 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,6 @@ permissions: env: CARGO_TERM_COLOR: always - PROTOC_VERSION: '3.25.3' SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" @@ -38,11 +37,6 @@ jobs: with: toolchain: nightly-2026-06-16 - - name: Install protoc - uses: taiki-e/install-action@v2 - with: - tool: protoc@${{ env.PROTOC_VERSION }} - - name: Install cargo-release uses: taiki-e/install-action@v2 with: diff --git a/Cargo.lock b/Cargo.lock index 63088e6b8..8068a4bf6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,9 +76,9 @@ dependencies = [ [[package]] name = "aes-gcm" -version = "0.11.0" +version = "0.11.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" +checksum = "da8c919c118108f144adecad74b425b804ad075580d605d9b33c2d6d1c62a2f8" dependencies = [ "aead 0.6.1", "aes 0.9.1", @@ -407,6 +407,62 @@ dependencies = [ "objc2", ] +[[package]] +name = "buffa" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f29a40702df4b86ccd84211bfde8cee0bce6d0811450ade4a86a7d0958a23a" +dependencies = [ + "base64", + "bytes", + "foldhash 0.1.5", + "hashbrown 0.15.5", + "once_cell", + "rustversion", + "serde", + "serde_json", + "smoothutf8 0.1.1", + "thiserror 2.0.18", +] + +[[package]] +name = "buffa-build" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef33217cabddfad99c0a54d42ddb7ca5b73fa492f7b16ddb621132ef51107556" +dependencies = [ + "buffa", + "buffa-codegen", + "tempfile", +] + +[[package]] +name = "buffa-codegen" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6681b562b18ea719622d0d12684e88ecbdca600d517557dc7bcef7704631e28" +dependencies = [ + "buffa", + "buffa-descriptor", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "thiserror 2.0.18", +] + +[[package]] +name = "buffa-descriptor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ed423c4ecec86d1500879ce42e7e5f6def01bc632985ac756c1fd723fa21fc" +dependencies = [ + "buffa", + "rustversion", + "serde", + "serde_json", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -1158,6 +1214,7 @@ version = "0.0.0" dependencies = [ "anyhow", "async-channel", + "buffa", "dhat", "env_logger", "futures", @@ -1315,12 +1372,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - [[package]] name = "flate2" version = "1.1.9" @@ -1580,6 +1631,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "foldhash 0.1.5", + "serde", ] [[package]] @@ -2137,12 +2189,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "multimap" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" - [[package]] name = "ndk" version = "0.9.0" @@ -2496,17 +2542,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "petgraph" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" -dependencies = [ - "fixedbitset", - "hashbrown 0.15.5", - "indexmap", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2588,6 +2623,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -2634,55 +2679,6 @@ dependencies = [ "unarray", ] -[[package]] -name = "prost" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-build" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" -dependencies = [ - "heck", - "itertools", - "log", - "multimap", - "petgraph", - "prost", - "prost-types", - "regex", - "tempfile", -] - -[[package]] -name = "prost-derive" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "prost-types" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" -dependencies = [ - "prost", -] - [[package]] name = "quanta" version = "0.12.6" @@ -3200,6 +3196,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -3331,6 +3338,15 @@ dependencies = [ "serde", ] +[[package]] +name = "smoothutf8" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36358427d32ecdb1624616deed99eccfef0a167fe5bf40ddb51efe6980bc1ec8" +dependencies = [ + "simdutf8", +] + [[package]] name = "smoothutf8" version = "0.2.3" @@ -3860,12 +3876,14 @@ name = "wacore" version = "0.6.0" dependencies = [ "aes 0.9.1", - "aes-gcm 0.11.0", + "aes-gcm 0.11.0-rc.4", "anyhow", "async-channel", "async-lock", "async-trait", "base64", + "buffa", + "buffa-build", "bytes", "chrono", "codspeed-divan-compat", @@ -3884,14 +3902,13 @@ dependencies = [ "md5", "metrics", "portable-atomic", - "prost", "rand 0.10.1", "serde", "serde-big-array", "serde_json", "sha1 0.11.0", "sha2 0.11.0", - "smoothutf8", + "smoothutf8 0.2.3", "subtle", "thiserror 2.0.18", "tokio", @@ -3910,13 +3927,13 @@ name = "wacore-appstate" version = "0.6.0" dependencies = [ "anyhow", + "buffa", "bytemuck", "codspeed-divan-compat", "hex", "hkdf 0.13.0", "hmac 0.13.0", "log", - "prost", "serde", "serde-big-array", "serde_json", @@ -3941,7 +3958,7 @@ dependencies = [ "serde", "serde_json", "smallvec", - "smoothutf8", + "smoothutf8 0.2.3", "stable_deref_trait", "yoke", "zlib-rs", @@ -3964,6 +3981,7 @@ dependencies = [ "arrayref", "async-lock", "async-trait", + "buffa", "bytes", "cbc 0.2.1", "chrono", @@ -3978,7 +3996,6 @@ dependencies = [ "hkdf 0.13.0", "hmac 0.13.0", "log", - "prost", "rand 0.10.1", "serde", "sha1 0.11.0", @@ -3995,11 +4012,11 @@ name = "wacore-noise" version = "0.6.0" dependencies = [ "anyhow", + "buffa", "bytes", "codspeed-divan-compat", "hkdf 0.13.0", "log", - "prost", "rand 0.10.1", "sha2 0.11.0", "thiserror 2.0.18", @@ -4040,11 +4057,14 @@ dependencies = [ name = "waproto" version = "0.6.0" dependencies = [ + "buffa", + "buffa-build", + "buffa-descriptor", + "bytes", "heck", - "prost", - "prost-build", - "prost-types", "serde", + "serde_json", + "serde_repr", "sha2 0.11.0", ] @@ -4248,6 +4268,7 @@ dependencies = [ "async-lock", "async-trait", "base64", + "buffa", "bytes", "cbc 0.2.1", "chrono", @@ -4264,7 +4285,6 @@ dependencies = [ "metrics-exporter-prometheus", "opus", "portable-atomic", - "prost", "rand 0.10.1", "rustls", "scopeguard", @@ -4295,15 +4315,17 @@ name = "whatsapp-rust-sqlite-storage" version = "0.6.0" dependencies = [ "async-trait", + "buffa", + "buffa-build", "bytes", "diesel", "diesel_migrations", "libsqlite3-sys", "log", "portable-atomic", - "prost", "scheduled-thread-pool", "serde_json", + "sha2 0.11.0", "tokio", "wacore", ] diff --git a/Cargo.toml b/Cargo.toml index cc2c9bea9..7f18d99b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,9 @@ async-channel = { version = "2.5.0", default-features = false, features = ["std" async-lock = { version = "3", default-features = false } async-trait = "0.1.89" base64 = { version = "0.22.1", default-features = false, features = ["alloc"] } +buffa = { version = "0.8.1", default-features = false, features = ["json"] } +buffa-build = { version = "0.8.1", default-features = false } +buffa-descriptor = { version = "0.8.1", default-features = false } bytemuck = { version = "1.25", default-features = false } bytes = { version = "1.12", default-features = false } cbc = { version = "0.2", features = ["alloc"] } @@ -77,9 +80,6 @@ itoa = "1" log = "0.4" metrics = "0.24" portable-atomic = { version = "1", default-features = false, features = ["fallback"] } -prost = { version = "0.14.4", default-features = false, features = ["std"] } -prost-build = { version = "0.14.4", default-features = false } -prost-types = { version = "0.14.4", default-features = false, features = ["std"] } rand = "0.10" serde = { version = "1.0", features = ["derive", "rc"] } serde-big-array = "0.5" @@ -158,6 +158,7 @@ async-channel = { workspace = true } async-lock = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } +buffa = { workspace = true } bytes = { workspace = true } chrono = { workspace = true, features = ["clock"] } event-listener = { workspace = true } @@ -167,7 +168,6 @@ itoa = { workspace = true } log = { workspace = true } opus = { version = "0.3", optional = true } portable-atomic = { workspace = true } -prost = { workspace = true } rand = { workspace = true } # Only with `voip`: lets us install the `ring` rustls CryptoProvider before the DTLS handshake. # webrtc-dtls picks rustls' process-default provider, and the tree carries both ring and aws-lc-rs, @@ -250,7 +250,7 @@ incremental = false # Off-hot-path crates run per connection / sync / request, not per message, # so size-optimizing them shrinks the binary at no measurable throughput cost. # The per-message/per-frame crypto/protocol crates (libsignal, wacore-binary, -# waproto, prost, aes, sha2, hkdf, flate2, curve25519, wacore-noise) stay at +# waproto, buffa, aes, sha2, hkdf, flate2, curve25519, wacore-noise) stay at # opt-level 3 — wacore-noise's NoiseCipher runs the transport AEAD per frame, # so despite owning the once-per-connection handshake it is not handshake-only. # z = persistence (Diesel/SQLite, I/O-bound), media HTTP (ureq, network-bound), diff --git a/examples/demo.rs b/examples/demo.rs index 48253e53b..936b3af5b 100644 --- a/examples/demo.rs +++ b/examples/demo.rs @@ -162,10 +162,10 @@ async fn handle_text_ping(ctx: &MessageContext) { ); let edit = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some(format!("{PONG_TEXT}\n`{duration}`")), ..Default::default() - })), + }), ..Default::default() }; if let Err(e) = ctx.edit_message(sent.message_id.clone(), edit).await { @@ -220,25 +220,25 @@ async fn handle_send_command(ctx: &MessageContext, args: &str) { fn build_media_pong(message: &wa::Message) -> Option { let base = message.get_base_message(); - if let Some(img) = &base.image_message + if let Some(img) = base.image_message.as_option() && img.caption.as_deref() == Some(PING_TRIGGER) { return Some(wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage { + image_message: buffa::MessageField::some(wa::message::ImageMessage { caption: Some(PONG_TEXT.to_string()), - ..*img.clone() - })), + ..img.clone() + }), ..Default::default() }); } - if let Some(vid) = &base.video_message + if let Some(vid) = base.video_message.as_option() && vid.caption.as_deref() == Some(PING_TRIGGER) { return Some(wa::Message { - video_message: Some(Box::new(wa::message::VideoMessage { + video_message: buffa::MessageField::some(wa::message::VideoMessage { caption: Some(PONG_TEXT.to_string()), - ..*vid.clone() - })), + ..vid.clone() + }), ..Default::default() }); } diff --git a/scripts/regenerate-tables-desc.sh b/scripts/regenerate-tables-desc.sh new file mode 100755 index 000000000..dfb984f23 --- /dev/null +++ b/scripts/regenerate-tables-desc.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Regenerate wacore/src/voip/mlow/tables.desc from tables.proto. +# +# Consumers never run this — they only need `cargo build` (with the `voip` +# feature), which reads the committed `.desc` and writes Rust source to +# `OUT_DIR`. Editors of the `.proto` run this once per edit and commit both +# files. +# +# Requires `protoc` on PATH. + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +proto_dir="$repo_root/wacore/src/voip/mlow" + +if ! command -v protoc >/dev/null 2>&1; then + echo "error: protoc not on PATH; install protobuf-compiler" >&2 + exit 1 +fi + +protoc \ + --descriptor_set_out="$proto_dir/tables.desc" \ + --include_imports \ + -I"$proto_dir" \ + "$proto_dir/tables.proto" + +hash_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +# Compute into vars first so `set -e` aborts on a hashing failure rather than +# writing a bogus (empty-hash) .sha256 from a command-substitution in printf. +proto_sha="$(hash_file "$proto_dir/tables.proto")" +desc_sha="$(hash_file "$proto_dir/tables.desc")" +{ + printf 'proto %s\n' "$proto_sha" + printf 'desc %s\n' "$desc_sha" +} > "$proto_dir/tables.desc.sha256" + +echo "regenerated: $proto_dir/tables.desc" +echo "regenerated: $proto_dir/tables.desc.sha256" +echo "commit wacore/src/voip/mlow/tables.proto, tables.desc, and tables.desc.sha256" diff --git a/scripts/regenerate-wire-desc.sh b/scripts/regenerate-wire-desc.sh new file mode 100755 index 000000000..3e53d1721 --- /dev/null +++ b/scripts/regenerate-wire-desc.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Regenerate storages/sqlite-storage/proto/wire.desc from wire.proto. +# +# Consumers never run this — they only need `cargo build`, which reads the +# committed `.desc` and writes Rust source to `OUT_DIR`. Editors of the +# `.proto` run this once per edit and commit both files. +# +# Requires `protoc` on PATH. + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +proto_dir="$repo_root/storages/sqlite-storage/proto" + +if ! command -v protoc >/dev/null 2>&1; then + echo "error: protoc not on PATH; install protobuf-compiler" >&2 + exit 1 +fi + +protoc \ + --descriptor_set_out="$proto_dir/wire.desc" \ + --include_imports \ + -I"$proto_dir" \ + "$proto_dir/wire.proto" + +hash_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +# Compute into vars first so `set -e` aborts on a hashing failure rather than +# writing a bogus (empty-hash) .sha256 from a command-substitution in printf. +proto_sha="$(hash_file "$proto_dir/wire.proto")" +desc_sha="$(hash_file "$proto_dir/wire.desc")" +{ + printf 'proto %s\n' "$proto_sha" + printf 'desc %s\n' "$desc_sha" +} > "$proto_dir/wire.desc.sha256" + +echo "regenerated: $proto_dir/wire.desc" +echo "regenerated: $proto_dir/wire.desc.sha256" +echo "commit storages/sqlite-storage/proto/wire.proto, wire.desc, and wire.desc.sha256" diff --git a/src/appstate_sync.rs b/src/appstate_sync.rs index 7b72e0c43..dc6f2c7bd 100644 --- a/src/appstate_sync.rs +++ b/src/appstate_sync.rs @@ -7,7 +7,7 @@ mod tests { use super::*; use async_lock::Mutex; use async_trait::async_trait; - use prost::Message; + use buffa::Message; use std::collections::HashMap; use std::sync::Arc; use wacore::appstate::WAPATCH_INTEGRITY; @@ -329,15 +329,15 @@ mod tests { value_blob.extend_from_slice(&value_mac); wa::SyncdMutation { - operation: Some(op as i32), - record: Some(wa::SyncdRecord { - index: Some(wa::SyncdIndex { + operation: Some(op), + record: buffa::MessageField::some(wa::SyncdRecord { + index: buffa::MessageField::some(wa::SyncdIndex { blob: Some(index_mac.to_vec()), }), - value: Some(wa::SyncdValue { + value: buffa::MessageField::some(wa::SyncdValue { blob: Some(value_blob), }), - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id_bytes.to_vec()), }), }), @@ -365,7 +365,7 @@ mod tests { .expect("test backend should accept sync key"); let original_plaintext = wa::SyncActionData { - value: Some(wa::SyncActionValue { + value: buffa::MessageField::some(wa::SyncActionValue { timestamp: Some(1000), ..Default::default() }), @@ -373,7 +373,7 @@ mod tests { } .encode_to_vec(); let original_mutation = create_encrypted_mutation( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &index_mac, &original_plaintext, &keys, @@ -394,8 +394,10 @@ mod tests { let original_value_blob = original_mutation .record + .into_option() .expect("mutation should have record") .value + .into_option() .expect("record should have value") .blob .expect("value should have blob"); @@ -413,7 +415,7 @@ mod tests { .expect("test backend should accept mutation MACs"); let new_plaintext = wa::SyncActionData { - value: Some(wa::SyncActionValue { + value: buffa::MessageField::some(wa::SyncActionValue { timestamp: Some(2000), ..Default::default() }), @@ -421,7 +423,7 @@ mod tests { } .encode_to_vec(); let overwrite_mutation = create_encrypted_mutation( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &index_mac, &new_plaintext, &keys, @@ -433,8 +435,8 @@ mod tests { has_more_patches: false, patches: vec![wa::SyncdPatch { mutations: vec![overwrite_mutation.clone()], - version: Some(wa::SyncdVersion { version: Some(2) }), - key_id: Some(wa::KeyId { + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(2) }), + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id_bytes), }), ..Default::default() @@ -456,8 +458,10 @@ mod tests { let mut expected_state = initial_state.clone(); let new_value_blob = overwrite_mutation .record + .into_option() .expect("mutation should have record") .value + .into_option() .expect("record should have value") .blob .expect("value should have blob"); @@ -528,7 +532,7 @@ mod tests { .expect("test backend should accept mutation MACs"); let plaintext = wa::SyncActionData { - value: Some(wa::SyncActionValue { + value: buffa::MessageField::some(wa::SyncActionValue { timestamp: Some(2000), ..Default::default() }), @@ -550,9 +554,9 @@ mod tests { has_more_patches: false, patches: vec![], snapshot: Some(wa::SyncdSnapshot { - version: Some(wa::SyncdVersion { version: Some(2) }), + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(2) }), records: vec![record], - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id_bytes), }), ..Default::default() @@ -576,7 +580,7 @@ mod tests { let master_key = [9u8; 32]; let keys = expand_app_state_keys(&master_key); let plaintext = wa::SyncActionData { - value: Some(wa::SyncActionValue { + value: buffa::MessageField::some(wa::SyncActionValue { timestamp: Some(3000), ..Default::default() }), @@ -593,10 +597,10 @@ mod tests { ); patch_list.patches.push(wa::SyncdPatch { mutations: vec![mutation], - version: Some(wa::SyncdVersion { + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(version), }), - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id_bytes.clone()), }), ..Default::default() @@ -617,7 +621,7 @@ mod tests { let snapshot = pl.snapshot.as_ref().expect("snapshot handed back"); assert_eq!( - snapshot.version.as_ref().and_then(|v| v.version), + snapshot.version.as_option().and_then(|v| v.version), Some(2), "the same snapshot must come back, not a substitute" ); @@ -626,7 +630,7 @@ mod tests { let patch_versions: Vec<_> = pl .patches .iter() - .map(|p| p.version.as_ref().and_then(|v| v.version)) + .map(|p| p.version.as_option().and_then(|v| v.version)) .collect(); assert_eq!( patch_versions, @@ -639,8 +643,8 @@ mod tests { .map(|p| { p.mutations[0] .record - .as_ref() - .and_then(|r| r.index.as_ref()) + .as_option() + .and_then(|r| r.index.as_option()) .and_then(|i| i.blob.as_deref()) .map(|b| b[0]) }) @@ -710,7 +714,7 @@ mod tests { .expect("test backend should accept mutation MACs"); let plaintext = wa::SyncActionData { - value: Some(wa::SyncActionValue { + value: buffa::MessageField::some(wa::SyncActionValue { timestamp: Some(5000), ..Default::default() }), @@ -774,7 +778,8 @@ mod tests { let expected_snapshot_mac = expected_state.generate_snapshot_mac(collection_name.as_str(), &keys.snapshot_mac); - let patch = wa::SyncdPatch::decode(patch_bytes.as_slice()).expect("patch should decode"); + let patch = + wa::SyncdPatch::decode_from_slice(patch_bytes.as_slice()).expect("patch should decode"); assert_eq!( patch.snapshot_mac.as_deref(), Some(expected_snapshot_mac.as_slice()), @@ -842,7 +847,7 @@ mod tests { name: WAPatchName::Regular, has_more_patches: false, patches: vec![wa::SyncdPatch { - version: Some(wa::SyncdVersion { version: Some(5) }), + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(5) }), ..Default::default() }], snapshot: None, @@ -902,7 +907,7 @@ mod tests { name, has_more_patches: false, patches: vec![wa::SyncdPatch { - version: Some(wa::SyncdVersion { version: Some(1) }), + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }), ..Default::default() }], snapshot: None, @@ -938,7 +943,7 @@ mod tests { let snapshot_key_id = b"snapshot-key-xyz".to_vec(); let snapshot_bytes = wa::SyncdSnapshot { - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(snapshot_key_id.clone()), }), ..Default::default() diff --git a/src/bot.rs b/src/bot.rs index 1e8f92ba6..3f7e8b1cc 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -839,7 +839,7 @@ impl BotBuilder { /// .with_device_props( /// DevicePropsOverride::new() /// .with_os("macOS") - /// .with_platform_type(PlatformType::Chrome), + /// .with_platform_type(PlatformType::CHROME), /// ); /// ``` pub fn with_device_props(mut self, override_: DevicePropsOverride) -> Self { @@ -1218,7 +1218,7 @@ mod tests { .with_device_props( DevicePropsOverride::new() .with_os(custom_os.clone()) - .with_version(custom_version), + .with_version(custom_version.clone()), ) .with_runtime(TokioRuntime) .build() @@ -1231,7 +1231,10 @@ mod tests { // Verify the device props were overridden assert_eq!(device.device_props.os, Some(custom_os)); - assert_eq!(device.device_props.version, Some(custom_version)); + assert_eq!( + device.device_props.version.as_option(), + Some(&custom_version) + ); } #[tokio::test] @@ -1260,8 +1263,8 @@ mod tests { assert_eq!(device.device_props.os, Some(custom_os)); // Version should be the default since we didn't override it assert_eq!( - device.device_props.version, - Some(wacore::store::Device::default_device_props_version()) + device.device_props.version.as_option(), + Some(&wacore::store::Device::default_device_props_version()) ); } @@ -1282,7 +1285,7 @@ mod tests { .with_backend_arc(backend) .with_http_client(http_client) .with_transport_factory(transport) - .with_device_props(DevicePropsOverride::new().with_version(custom_version)) + .with_device_props(DevicePropsOverride::new().with_version(custom_version.clone())) .with_runtime(TokioRuntime) .build() .await @@ -1293,7 +1296,10 @@ mod tests { let device = persistence_manager.get_device_snapshot(); // Verify only version was overridden, OS should be default ("rust") - assert_eq!(device.device_props.version, Some(custom_version)); + assert_eq!( + device.device_props.version.as_option(), + Some(&custom_version) + ); // OS should be the default since we didn't override it assert_eq!( device.device_props.os, @@ -1313,7 +1319,7 @@ mod tests { .with_http_client(http_client) .with_device_props( DevicePropsOverride::new() - .with_platform_type(wa::device_props::PlatformType::Chrome), + .with_platform_type(wa::device_props::PlatformType::CHROME), ) .with_runtime(TokioRuntime) .build() @@ -1327,7 +1333,7 @@ mod tests { // Verify platform type was set to Chrome assert_eq!( device.device_props.platform_type, - Some(wa::device_props::PlatformType::Chrome as i32) + Some(wa::device_props::PlatformType::CHROME) ); // OS and version should remain default assert_eq!( @@ -1335,8 +1341,8 @@ mod tests { Some(wacore::store::Device::default_os().to_string()) ); assert_eq!( - device.device_props.version, - Some(wacore::store::Device::default_device_props_version()) + device.device_props.version.as_option(), + Some(&wacore::store::Device::default_device_props_version()) ); } @@ -1353,7 +1359,7 @@ mod tests { tertiary: Some(0), ..Default::default() }; - let custom_platform = wa::device_props::PlatformType::Safari; + let custom_platform = wa::device_props::PlatformType::SAFARI; let bot = Bot::builder() .with_backend_arc(backend) @@ -1362,7 +1368,7 @@ mod tests { .with_device_props( DevicePropsOverride::new() .with_os(custom_os.clone()) - .with_version(custom_version) + .with_version(custom_version.clone()) .with_platform_type(custom_platform), ) .with_runtime(TokioRuntime) @@ -1376,11 +1382,11 @@ mod tests { // Verify all device props were overridden assert_eq!(device.device_props.os, Some(custom_os)); - assert_eq!(device.device_props.version, Some(custom_version)); assert_eq!( - device.device_props.platform_type, - Some(custom_platform as i32) + device.device_props.version.as_option(), + Some(&custom_version) ); + assert_eq!(device.device_props.platform_type, Some(custom_platform)); } #[tokio::test] diff --git a/src/client/app_state.rs b/src/client/app_state.rs index e6236562f..da7c8a9f4 100644 --- a/src/client/app_state.rs +++ b/src/client/app_state.rs @@ -249,7 +249,7 @@ impl Client { // Download external mutations for patch in &pl.patches { - if let Some(ext) = &patch.external_mutations + if let Some(ext) = patch.external_mutations.as_option() && let Some(path) = &ext.direct_path { match self.download(ext).await { @@ -257,8 +257,11 @@ impl Client { pre_downloaded.insert(path.clone(), bytes); } Err(e) => { - let v = - patch.version.as_ref().and_then(|v| v.version).unwrap_or(0); + let v = patch + .version + .as_option() + .and_then(|v| v.version) + .unwrap_or(0); warn!( "Failed to download external mutations for patch v{}: {e}", v @@ -496,11 +499,14 @@ impl Client { // Download external mutations for each patch that has them for patch in &pl.patches { - if let Some(ext) = &patch.external_mutations + if let Some(ext) = patch.external_mutations.as_option() && let Some(path) = &ext.direct_path { - let patch_version = - patch.version.as_ref().and_then(|v| v.version).unwrap_or(0); + let patch_version = patch + .version + .as_option() + .and_then(|v| v.version) + .unwrap_or(0); match self.download(ext).await { Ok(bytes) => { debug!(target: "Client/AppState", "Downloaded external mutations for patch v{} ({} bytes)", patch_version, bytes.len()); @@ -682,11 +688,13 @@ impl Client { }) .collect(); let msg = wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage { - r#type: Some(wa::message::protocol_message::Type::AppStateSyncKeyRequest as i32), - app_state_sync_key_request: Some(wa::message::AppStateSyncKeyRequest { key_ids }), + protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage { + r#type: Some(wa::message::protocol_message::Type::AppStateSyncKeyRequest), + app_state_sync_key_request: buffa::MessageField::some( + wa::message::AppStateSyncKeyRequest { key_ids }, + ), ..Default::default() - })), + }), ..Default::default() }; self.send_message_impl( @@ -765,7 +773,7 @@ impl Client { .process_command(DeviceCommand::SetNctSalt(None)) .await; } else if let Some(val) = &m.action_value - && let Some(act) = &val.nct_salt_sync_action + && let Some(act) = val.nct_salt_sync_action.as_option() && let Some(salt) = &act.salt { if salt.is_empty() { @@ -802,7 +810,7 @@ impl Client { // Handle client-internal mutations that need persistence/presence access if m.index[0] == "setting_pushName" && let Some(val) = &m.action_value - && let Some(act) = &val.push_name_setting + && let Some(act) = val.push_name_setting.as_option() && let Some(new_name) = &act.name { let new_name = new_name.clone(); diff --git a/src/client/device_registry.rs b/src/client/device_registry.rs index fc07502d2..724432866 100644 --- a/src/client/device_registry.rs +++ b/src/client/device_registry.rs @@ -1850,8 +1850,8 @@ mod tests { current_index: u32, valid_indexes: Vec, ) -> Vec { - use prost::Message; - let details = waproto::whatsapp::AdvKeyIndexList { + use buffa::Message; + let details = waproto::whatsapp::ADVKeyIndexList { raw_id: Some(raw_id), timestamp: Some(100), current_index: Some(current_index), @@ -1859,7 +1859,7 @@ mod tests { account_type: None, } .encode_to_vec(); - waproto::whatsapp::AdvSignedKeyIndexList { + waproto::whatsapp::ADVSignedKeyIndexList { details: Some(details), account_signature: None, account_signature_key: None, diff --git a/src/client/iq_ops.rs b/src/client/iq_ops.rs index 008bf5c31..0a8659de8 100644 --- a/src/client/iq_ops.rs +++ b/src/client/iq_ops.rs @@ -200,17 +200,17 @@ impl Client { fn build_ephemeral_setting_message(duration: u32, now_secs: i64) -> waproto::whatsapp::Message { use waproto::whatsapp as wa; wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage { - r#type: Some(wa::message::protocol_message::Type::EphemeralSetting as i32), + protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage { + r#type: Some(wa::message::protocol_message::Type::EphemeralSetting), ephemeral_expiration: Some(duration), ephemeral_setting_timestamp: Some(now_secs), - disappearing_mode: Some(wa::DisappearingMode { - initiator: Some(wa::disappearing_mode::Initiator::ChangedInChat as i32), - trigger: Some(wa::disappearing_mode::Trigger::Unknown as i32), + disappearing_mode: buffa::MessageField::some(wa::DisappearingMode { + initiator: Some(wa::disappearing_mode::Initiator::ChangedInChat), + trigger: Some(wa::disappearing_mode::Trigger::Unknown), ..Default::default() }), ..Default::default() - })), + }), ..Default::default() } } @@ -223,27 +223,36 @@ mod tests { #[test] fn ephemeral_setting_message_shape() { let msg = build_ephemeral_setting_message(86400, 1_700_000_000); - let pm = msg.protocol_message.expect("protocol_message set"); + let pm = msg + .protocol_message + .as_option() + .expect("protocol_message set"); assert_eq!( pm.r#type, - Some(wa::message::protocol_message::Type::EphemeralSetting as i32) + Some(wa::message::protocol_message::Type::EphemeralSetting) ); assert_eq!(pm.ephemeral_expiration, Some(86400)); assert_eq!(pm.ephemeral_setting_timestamp, Some(1_700_000_000)); - let dm = pm.disappearing_mode.expect("disappearing_mode set"); + let dm = pm + .disappearing_mode + .as_option() + .expect("disappearing_mode set"); assert_eq!( dm.initiator, - Some(wa::disappearing_mode::Initiator::ChangedInChat as i32) - ); - assert_eq!( - dm.trigger, - Some(wa::disappearing_mode::Trigger::Unknown as i32) + Some(wa::disappearing_mode::Initiator::ChangedInChat) ); + assert_eq!(dm.trigger, Some(wa::disappearing_mode::Trigger::Unknown)); } #[test] fn ephemeral_setting_disable_uses_zero_duration() { let msg = build_ephemeral_setting_message(0, 1); - assert_eq!(msg.protocol_message.unwrap().ephemeral_expiration, Some(0)); + assert_eq!( + msg.protocol_message + .as_option() + .unwrap() + .ephemeral_expiration, + Some(0) + ); } } diff --git a/src/client/lid_pn.rs b/src/client/lid_pn.rs index 120e58c10..b0a7dc151 100644 --- a/src/client/lid_pn.rs +++ b/src/client/lid_pn.rs @@ -475,16 +475,17 @@ impl Client { /// migrated so DMs switch to LID wire addressing. pub(crate) async fn handle_lid_migration_mapping_sync( self: &Arc, - sync: &waproto::whatsapp::LidMigrationMappingSyncMessage, + sync: &waproto::whatsapp::LIDMigrationMappingSyncMessage, ) { - use prost::Message as _; + use buffa::Message as _; let Some(payload_bytes) = sync.encoded_mapping_payload.as_deref() else { log::warn!("lid_migration_mapping_sync without payload"); return; }; - let payload = match waproto::whatsapp::LidMigrationMappingSyncPayload::decode(payload_bytes) - { + let payload = match waproto::whatsapp::LIDMigrationMappingSyncPayload::decode_from_slice( + payload_bytes, + ) { Ok(p) => p, Err(e) => { log::warn!("Failed to decode LID migration mapping payload: {e}"); @@ -957,19 +958,19 @@ mod tests { #[tokio::test] async fn test_lid_migration_mapping_sync_learns_and_migrates_with_prop() { - use prost::Message as _; + use buffa::Message as _; use waproto::whatsapp as wa; let client: Arc = create_test_client().await; - let payload = wa::LidMigrationMappingSyncPayload { - pn_to_lid_mappings: vec![wa::LidMigrationMapping { + let payload = wa::LIDMigrationMappingSyncPayload { + pn_to_lid_mappings: vec![wa::LIDMigrationMapping { pn: 5511987650001, assigned_lid: 111000011112222, latest_lid: None, }], chat_db_migration_timestamp: None, }; - let sync = wa::LidMigrationMappingSyncMessage { + let sync = wa::LIDMigrationMappingSyncMessage { encoded_mapping_payload: Some(payload.encode_to_vec()), }; @@ -1042,7 +1043,7 @@ mod tests { // Missing payload: WA Web treats this as malformed; nothing is // learned and the account must not flip to migrated. - let missing = wa::LidMigrationMappingSyncMessage { + let missing = wa::LIDMigrationMappingSyncMessage { encoded_mapping_payload: None, }; client.handle_lid_migration_mapping_sync(&missing).await; @@ -1053,7 +1054,7 @@ mod tests { .lid_migrated ); - let malformed = wa::LidMigrationMappingSyncMessage { + let malformed = wa::LIDMigrationMappingSyncMessage { encoded_mapping_payload: Some(vec![0xFF, 0xFF, 0xFF]), }; client.handle_lid_migration_mapping_sync(&malformed).await; @@ -1067,19 +1068,19 @@ mod tests { #[tokio::test] async fn test_lid_migration_mapping_sync_prefers_latest_lid() { - use prost::Message as _; + use buffa::Message as _; use waproto::whatsapp as wa; let client: Arc = create_test_client().await; - let payload = wa::LidMigrationMappingSyncPayload { - pn_to_lid_mappings: vec![wa::LidMigrationMapping { + let payload = wa::LIDMigrationMappingSyncPayload { + pn_to_lid_mappings: vec![wa::LIDMigrationMapping { pn: 5511987650001, assigned_lid: 111000011112222, latest_lid: Some(999000099990000), }], chat_db_migration_timestamp: None, }; - let sync = wa::LidMigrationMappingSyncMessage { + let sync = wa::LIDMigrationMappingSyncMessage { encoded_mapping_payload: Some(payload.encode_to_vec()), }; @@ -1484,14 +1485,14 @@ mod tests { remote_identity_public: None, root_key: None, previous_counter: Some(0), - sender_chain: None, + sender_chain: buffa::MessageField::none(), receiver_chains: vec![], - pending_pre_key: None, + pending_pre_key: buffa::MessageField::none(), remote_registration_id: Some(remote_regid), local_registration_id: Some(0), alice_base_key: Some(vec![]), needs_refresh: None, - pending_key_exchange: None, + pending_key_exchange: buffa::MessageField::none(), }); SessionRecord::new(state) .serialize() diff --git a/src/client/messaging.rs b/src/client/messaging.rs index 57d72be1f..e279ba6d7 100644 --- a/src/client/messaging.rs +++ b/src/client/messaging.rs @@ -419,8 +419,8 @@ fn build_secret_message_edit( wacore::message_edit::encrypt_message_edit(&inner, message_secret, &ctx)?; Ok(wa::Message { - secret_encrypted_message: Some(Box::new(wa::message::SecretEncryptedMessage { - target_message_key: Some(wa::MessageKey { + secret_encrypted_message: buffa::MessageField::some(wa::message::SecretEncryptedMessage { + target_message_key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some(to.to_string()), from_me: Some(true), id: Some(original_id.to_string()), @@ -429,14 +429,14 @@ fn build_secret_message_edit( enc_payload: Some(enc_payload), enc_iv: Some(iv.to_vec()), secret_enc_type: Some( - wa::message::secret_encrypted_message::SecretEncType::MessageEdit as i32, + wa::message::secret_encrypted_message::SecretEncType::MessageEdit, ), remote_key_id: None, - })), - message_context_info: Some(Box::new(wa::MessageContextInfo { + }), + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(message_secret.to_vec()), ..Default::default() - })), + }), ..Default::default() }) } @@ -458,16 +458,16 @@ mod secret_message_edit_tests { let envelope = build_secret_message_edit(&to, "ORIGID", None, self_str, &secret, new_content).unwrap(); - let sem = envelope.secret_encrypted_message.as_ref().unwrap(); + let sem = envelope.secret_encrypted_message.as_option().unwrap(); assert_eq!( sem.secret_enc_type, - Some(wa::message::secret_encrypted_message::SecretEncType::MessageEdit as i32) + Some(wa::message::secret_encrypted_message::SecretEncType::MessageEdit) ); // The envelope carries the original secret (WAWebGenerateSecretMessageEditProto). assert_eq!( envelope .message_context_info - .as_ref() + .as_option() .and_then(|c| c.message_secret.as_deref()), Some(&secret[..]) ); @@ -487,7 +487,8 @@ mod secret_message_edit_tests { .unwrap(); let edited = inner .protocol_message - .and_then(|pm| pm.edited_message) + .into_option() + .and_then(|pm| pm.edited_message.into_option()) .and_then(|m| m.conversation); assert_eq!(edited.as_deref(), Some("edited!")); } @@ -509,7 +510,7 @@ mod secret_message_edit_tests { }, ) .unwrap(); - let sem = envelope.secret_encrypted_message.as_ref().unwrap(); + let sem = envelope.secret_encrypted_message.as_option().unwrap(); let ctx = wacore::message_edit::MessageEditContext { original_msg_id: "ORIGID", original_sender_jid: self_str, diff --git a/src/features/chat_actions.rs b/src/features/chat_actions.rs index 690d3b4dc..70fddf8ee 100644 --- a/src/features/chat_actions.rs +++ b/src/features/chat_actions.rs @@ -51,7 +51,7 @@ pub fn message_range( messages: messages .into_iter() .map(|(key, ts)| wa::sync_action_value::SyncActionMessage { - key: Some(key), + key: buffa::MessageField::some(key), timestamp: Some(ts), }) .collect(), @@ -78,7 +78,7 @@ pub(crate) fn dispatch_chat_mutation( m: &Mutation, full_sync: bool, ) -> bool { - if m.operation != wa::syncd_mutation::SyncdOperation::Set || m.index.is_empty() { + if m.operation != wa::syncd_mutation::SyncdOperation::SET || m.index.is_empty() { return false; } @@ -128,12 +128,12 @@ pub(crate) fn dispatch_chat_mutation( match kind.as_str() { "mute" => { if let Some(val) = &m.action_value - && let Some(act) = &val.mute_action + && let Some(act) = val.mute_action.as_option() { event_bus.dispatch(Event::MuteUpdate(MuteUpdate { jid, timestamp: time, - action: Box::new(*act), + action: Box::new(act.clone()), from_full_sync: full_sync, })); } @@ -141,12 +141,12 @@ pub(crate) fn dispatch_chat_mutation( } "pin" | "pin_v1" => { if let Some(val) = &m.action_value - && let Some(act) = &val.pin_action + && let Some(act) = val.pin_action.as_option() { event_bus.dispatch(Event::PinUpdate(PinUpdate { jid, timestamp: time, - action: Box::new(*act), + action: Box::new(act.clone()), from_full_sync: full_sync, })); } @@ -154,7 +154,7 @@ pub(crate) fn dispatch_chat_mutation( } "archive" => { if let Some(val) = &m.action_value - && let Some(act) = &val.archive_chat_action + && let Some(act) = val.archive_chat_action.as_option() { event_bus.dispatch(Event::ArchiveUpdate(ArchiveUpdate { jid, @@ -167,7 +167,7 @@ pub(crate) fn dispatch_chat_mutation( } "star" => { if let Some(val) = &m.action_value - && let Some(act) = &val.star_action + && let Some(act) = val.star_action.as_option() && let Some((message_id, from_me, participant_jid)) = parse_message_key_fields(kind, &m.index) { @@ -177,7 +177,7 @@ pub(crate) fn dispatch_chat_mutation( message_id, from_me, timestamp: time, - action: Box::new(*act), + action: Box::new(act.clone()), from_full_sync: full_sync, })); } @@ -185,7 +185,7 @@ pub(crate) fn dispatch_chat_mutation( } "contact" => { if let Some(val) = &m.action_value - && let Some(act) = &val.contact_action + && let Some(act) = val.contact_action.as_option() { event_bus.dispatch(Event::ContactUpdate(ContactUpdate { jid, @@ -198,7 +198,7 @@ pub(crate) fn dispatch_chat_mutation( } "mark_chat_as_read" | "markChatAsRead" => { if let Some(val) = &m.action_value - && let Some(act) = &val.mark_chat_as_read_action + && let Some(act) = val.mark_chat_as_read_action.as_option() { event_bus.dispatch(Event::MarkChatAsReadUpdate(MarkChatAsReadUpdate { jid, @@ -211,7 +211,7 @@ pub(crate) fn dispatch_chat_mutation( } "deleteChat" => { if let Some(val) = &m.action_value - && let Some(act) = &val.delete_chat_action + && let Some(act) = val.delete_chat_action.as_option() { // delete_media is in index[2], not in the proto (which only has messageRange) let delete_media = m.index.get(2).is_none_or(|v| v != "0"); @@ -227,7 +227,7 @@ pub(crate) fn dispatch_chat_mutation( } "clearChat" => { if let Some(val) = &m.action_value - && let Some(act) = &val.clear_chat_action + && let Some(act) = val.clear_chat_action.as_option() { // deleteStarred/deleteMedia live in the index (index[2]/index[3]), // not in ClearChatAction (which only has messageRange). WA Web's send @@ -247,13 +247,13 @@ pub(crate) fn dispatch_chat_mutation( } "userStatusMute" => { if let Some(val) = &m.action_value - && let Some(act) = &val.user_status_mute_action + && let Some(act) = val.user_status_mute_action.as_option() { event_bus.dispatch(Event::UserStatusMuteUpdate(UserStatusMuteUpdate { jid, muted: act.muted.unwrap_or(false), timestamp: time, - action: Box::new(*act), + action: Box::new(act.clone()), from_full_sync: full_sync, })); } @@ -261,7 +261,7 @@ pub(crate) fn dispatch_chat_mutation( } "deleteMessageForMe" => { if let Some(val) = &m.action_value - && let Some(act) = &val.delete_message_for_me_action + && let Some(act) = val.delete_message_for_me_action.as_option() && let Some((message_id, from_me, participant_jid)) = parse_message_key_fields(kind, &m.index) { @@ -271,7 +271,7 @@ pub(crate) fn dispatch_chat_mutation( message_id, from_me, timestamp: time, - action: Box::new(*act), + action: Box::new(act.clone()), from_full_sync: full_sync, })); } @@ -484,10 +484,12 @@ impl<'a> ChatActions<'a> { if read { "read" } else { "unread" } ); let value = wa::SyncActionValue { - mark_chat_as_read_action: Some(wa::sync_action_value::MarkChatAsReadAction { - read: Some(read), - message_range, - }), + mark_chat_as_read_action: buffa::MessageField::some( + wa::sync_action_value::MarkChatAsReadAction { + read: Some(read), + message_range: message_range.into(), + }, + ), timestamp: Some(wacore::time::now_millis()), ..Default::default() }; @@ -506,7 +508,11 @@ impl<'a> ChatActions<'a> { debug!("Deleting chat {jid}"); let delete_media_str = if delete_media { "1" } else { "0" }; let value = wa::SyncActionValue { - delete_chat_action: Some(wa::sync_action_value::DeleteChatAction { message_range }), + delete_chat_action: buffa::MessageField::some( + wa::sync_action_value::DeleteChatAction { + message_range: message_range.into(), + }, + ), timestamp: Some(wacore::time::now_millis()), ..Default::default() }; @@ -536,7 +542,9 @@ impl<'a> ChatActions<'a> { let delete_starred_str = if delete_starred { "1" } else { "0" }; let delete_media_str = if delete_media { "1" } else { "0" }; let value = wa::SyncActionValue { - clear_chat_action: Some(wa::sync_action_value::ClearChatAction { message_range }), + clear_chat_action: buffa::MessageField::some(wa::sync_action_value::ClearChatAction { + message_range: message_range.into(), + }), timestamp: Some(wacore::time::now_millis()), ..Default::default() }; @@ -555,9 +563,9 @@ impl<'a> ChatActions<'a> { pub async fn set_user_status_mute(&self, jid: &Jid, muted: bool) -> Result<(), AppStateError> { debug!("Setting userStatusMute for {jid} -> {muted}"); let value = wa::SyncActionValue { - user_status_mute_action: Some(wa::sync_action_value::UserStatusMuteAction { - muted: Some(muted), - }), + user_status_mute_action: buffa::MessageField::some( + wa::sync_action_value::UserStatusMuteAction { muted: Some(muted) }, + ), timestamp: Some(wacore::time::now_millis()), ..Default::default() }; @@ -581,10 +589,12 @@ impl<'a> ChatActions<'a> { debug!("Deleting message {message_id} for me in {chat_jid}"); let (chat, participant) = message_key_owned(chat_jid, participant_jid, from_me)?; let value = wa::SyncActionValue { - delete_message_for_me_action: Some(wa::sync_action_value::DeleteMessageForMeAction { - delete_media: Some(delete_media), - message_timestamp, - }), + delete_message_for_me_action: buffa::MessageField::some( + wa::sync_action_value::DeleteMessageForMeAction { + delete_media: Some(delete_media), + message_timestamp, + }, + ), timestamp: Some(wacore::time::now_millis()), ..Default::default() }; @@ -626,7 +636,7 @@ impl<'a> ChatActions<'a> { } debug!("Saving contact {jid}"); let value = wa::SyncActionValue { - contact_action: Some(wa::sync_action_value::ContactAction { + contact_action: buffa::MessageField::some(wa::sync_action_value::ContactAction { full_name, first_name, save_on_primary_addressbook: Some(save_on_primary_addressbook), @@ -648,10 +658,12 @@ impl<'a> ChatActions<'a> { message_range: Option, ) -> Result<(), AppStateError> { let value = wa::SyncActionValue { - archive_chat_action: Some(wa::sync_action_value::ArchiveChatAction { - archived: Some(archived), - message_range, - }), + archive_chat_action: buffa::MessageField::some( + wa::sync_action_value::ArchiveChatAction { + archived: Some(archived), + message_range: message_range.into(), + }, + ), timestamp: Some(wacore::time::now_millis()), ..Default::default() }; @@ -663,7 +675,7 @@ impl<'a> ChatActions<'a> { async fn send_pin_mutation(&self, jid: &Jid, pinned: bool) -> Result<(), AppStateError> { let value = wa::SyncActionValue { - pin_action: Some(wa::sync_action_value::PinAction { + pin_action: buffa::MessageField::some(wa::sync_action_value::PinAction { pinned: Some(pinned), }), timestamp: Some(wacore::time::now_millis()), @@ -688,7 +700,7 @@ impl<'a> ChatActions<'a> { Some(0) }; let value = wa::SyncActionValue { - mute_action: Some(wa::sync_action_value::MuteAction { + mute_action: buffa::MessageField::some(wa::sync_action_value::MuteAction { muted: Some(muted), mute_end_timestamp: mute_end, ..Default::default() @@ -712,7 +724,7 @@ impl<'a> ChatActions<'a> { ) -> Result<(), AppStateError> { let (chat, participant) = message_key_owned(chat_jid, participant_jid, from_me)?; let value = wa::SyncActionValue { - star_action: Some(wa::sync_action_value::StarAction { + star_action: buffa::MessageField::some(wa::sync_action_value::StarAction { starred: Some(starred), }), timestamp: Some(wacore::time::now_millis()), @@ -769,7 +781,7 @@ impl Client { rand::make_rng::().fill_bytes(&mut iv); let (mutation, _) = encode_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, index, value, &keys, @@ -798,7 +810,7 @@ impl Client { /// use whatsapp_rust::schemas; /// use whatsapp_rust::waproto::whatsapp as wa; /// let value = wa::SyncActionValue { - /// clear_chat_action: Some(Default::default()), + /// clear_chat_action: Some(Default::default()).into(), /// timestamp: Some(1_700_000_000_000), // a real epoch-ms timestamp /// ..Default::default() /// }; diff --git a/src/features/comments.rs b/src/features/comments.rs index b44bf786b..16bf40c40 100644 --- a/src/features/comments.rs +++ b/src/features/comments.rs @@ -41,10 +41,10 @@ impl<'a> Comments<'a> { let chat = &chat.into(); // WA Web encryptExtendedTextComment: the body is an extendedTextMessage. let body = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some(text.to_string()), ..Default::default() - })), + }), ..Default::default() }; self.send_message(chat, parent_key, body).await @@ -97,15 +97,15 @@ impl<'a> Comments<'a> { }; let message = wa::Message { - enc_comment_message: Some(Box::new(wa::message::EncCommentMessage { - target_message_key: Some(parent_key), + enc_comment_message: buffa::MessageField::some(wa::message::EncCommentMessage { + target_message_key: buffa::MessageField::some(parent_key), enc_payload: Some(enc_payload), enc_iv: Some(iv.to_vec()), - })), - message_context_info: Some(Box::new(wa::MessageContextInfo { + }), + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(comment_secret.to_vec()), ..Default::default() - })), + }), ..Default::default() }; let result = client.send_message(chat, message).await?; diff --git a/src/features/events.rs b/src/features/events.rs index 91bda812f..5d79d19a3 100644 --- a/src/features/events.rs +++ b/src/features/events.rs @@ -46,7 +46,7 @@ impl<'a> Events<'a> { } let mut message = wa::Message { - event_message: Some(Box::new(build_event_message(params))), + event_message: buffa::MessageField::some(build_event_message(params)), ..Default::default() }; @@ -59,10 +59,10 @@ impl<'a> Events<'a> { rand::make_rng::().fill_bytes(&mut secret); secret }; - message.message_context_info = Some(Box::new(wa::MessageContextInfo { + message.message_context_info = buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(message_secret.clone()), ..Default::default() - })); + }); let result = self.client.send_message(to, message).await?; Ok((result, message_secret)) @@ -98,7 +98,7 @@ impl<'a> Events<'a> { let creator_str = event_creator_jid.to_non_ad_string(); let response_msg = wa::message::EventResponseMessage { - response: Some(response as i32), + response: Some(response), timestamp_ms: Some(wacore::time::now_millis()), extra_guest_count, }; @@ -113,7 +113,7 @@ impl<'a> Events<'a> { let from_me = my_base.is_same_user_as(event_creator_jid); let enc = wa::message::EncEventResponseMessage { - event_creation_message_key: Some(wa::MessageKey { + event_creation_message_key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some(chat_jid.to_string()), from_me: Some(from_me), id: Some(event_msg_id.to_string()), @@ -128,7 +128,7 @@ impl<'a> Events<'a> { }; let message = wa::Message { - enc_event_response_message: Some(Box::new(enc)), + enc_event_response_message: buffa::MessageField::some(enc), ..Default::default() }; @@ -164,7 +164,7 @@ fn build_event_message(params: EventCreationParams) -> wa::message::EventMessage start_time: params.start_time, end_time: params.end_time, join_link: params.join_link, - location: params.location.map(Box::new), + location: params.location.into(), is_schedule_call: params.is_scheduled_call, extra_guests_allowed: params.extra_guests_allowed, ..Default::default() diff --git a/src/features/labels.rs b/src/features/labels.rs index 6bcf65fb3..073dcfc97 100644 --- a/src/features/labels.rs +++ b/src/features/labels.rs @@ -48,7 +48,7 @@ pub(crate) fn dispatch_label_mutation( match kind { "label_edit" => { if let Some(val) = &m.action_value - && let Some(act) = &val.label_edit_action + && let Some(act) = val.label_edit_action.as_option() { event_bus.dispatch(Event::LabelEditUpdate(LabelEditUpdate { label_id, @@ -74,7 +74,7 @@ pub(crate) fn dispatch_label_mutation( } }; if let Some(val) = &m.action_value - && let Some(act) = &val.label_association_action + && let Some(act) = val.label_association_action.as_option() { event_bus.dispatch(Event::LabelAssociationUpdate(LabelAssociationUpdate { label_id, @@ -125,7 +125,7 @@ impl<'a> Labels<'a> { name.len() ); let value = wa::SyncActionValue { - label_edit_action: Some(wa::sync_action_value::LabelEditAction { + label_edit_action: buffa::MessageField::some(wa::sync_action_value::LabelEditAction { name: Some(name.to_string()), color: Some(color), deleted: Some(false), @@ -149,7 +149,7 @@ impl<'a> Labels<'a> { } debug!("Deleting label {label_id}"); let value = wa::SyncActionValue { - label_edit_action: Some(wa::sync_action_value::LabelEditAction { + label_edit_action: buffa::MessageField::some(wa::sync_action_value::LabelEditAction { deleted: Some(true), ..Default::default() }), @@ -197,10 +197,12 @@ impl<'a> Labels<'a> { ); let chat = chat_jid.to_string(); let value = wa::SyncActionValue { - label_association_action: Some(wa::sync_action_value::LabelAssociationAction { - labeled: Some(labeled), - ..Default::default() - }), + label_association_action: buffa::MessageField::some( + wa::sync_action_value::LabelAssociationAction { + labeled: Some(labeled), + ..Default::default() + }, + ), timestamp: Some(wacore::time::now_millis()), ..Default::default() }; @@ -257,12 +259,14 @@ mod tests { let m = set_mutation( vec!["label_edit", "5"], wa::SyncActionValue { - label_edit_action: Some(wa::sync_action_value::LabelEditAction { - name: Some("Work".into()), - color: Some(2), - deleted: Some(false), - ..Default::default() - }), + label_edit_action: buffa::MessageField::some( + wa::sync_action_value::LabelEditAction { + name: Some("Work".into()), + color: Some(2), + deleted: Some(false), + ..Default::default() + }, + ), timestamp: Some(1000), ..Default::default() }, @@ -286,10 +290,12 @@ mod tests { let m = set_mutation( vec!["label_jid", "5", "15551112222@s.whatsapp.net"], wa::SyncActionValue { - label_association_action: Some(wa::sync_action_value::LabelAssociationAction { - labeled: Some(true), - ..Default::default() - }), + label_association_action: buffa::MessageField::some( + wa::sync_action_value::LabelAssociationAction { + labeled: Some(true), + ..Default::default() + }, + ), timestamp: Some(1000), ..Default::default() }, @@ -347,10 +353,12 @@ mod tests { let m = set_mutation( vec!["label_jid", "5", "not a jid"], wa::SyncActionValue { - label_association_action: Some(wa::sync_action_value::LabelAssociationAction { - labeled: Some(true), - ..Default::default() - }), + label_association_action: buffa::MessageField::some( + wa::sync_action_value::LabelAssociationAction { + labeled: Some(true), + ..Default::default() + }, + ), ..Default::default() }, ); diff --git a/src/features/message_edit.rs b/src/features/message_edit.rs index 1efccb041..db7e09c8b 100644 --- a/src/features/message_edit.rs +++ b/src/features/message_edit.rs @@ -23,6 +23,7 @@ //! Mirrors the existing flow for poll vote decryption (`Polls::decrypt_vote`). use anyhow::{Result, anyhow}; +use buffa::MessageField; use log::warn; use wacore::message_edit::{self, MessageEditContext}; use wacore::secret_enc_addon::ModificationType; @@ -123,16 +124,16 @@ pub fn extract_envelope(msg: &wa::Message) -> Option> { /// decrypted message did not contain `protocol_message.edited_message` /// (caller should log + skip). pub fn rewrap_as_legacy_edit(inner: wa::Message) -> Option { - let pm = inner.protocol_message?; - let edited = pm.edited_message?; + let pm = inner.protocol_message.into_option()?; + let edited = pm.edited_message.into_option()?; Some(wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage { + protocol_message: MessageField::some(wa::message::ProtocolMessage { key: pm.key, - r#type: Some(wa::message::protocol_message::Type::MessageEdit as i32), - edited_message: Some(edited), + r#type: Some(wa::message::protocol_message::Type::MESSAGE_EDIT), + edited_message: MessageField::some(edited), timestamp_ms: pm.timestamp_ms, ..Default::default() - })), + }), ..Default::default() }) } @@ -242,11 +243,11 @@ impl SecretEncKind { fn from_proto(t: wa::message::secret_encrypted_message::SecretEncType) -> Option { use wa::message::secret_encrypted_message::SecretEncType as T; match t { - T::EventEdit => Some(Self::EventEdit), - T::MessageEdit => Some(Self::MessageEdit), - T::PollEdit => Some(Self::PollEdit), - T::PollAddOption => Some(Self::PollAddOption), - T::MessageSchedule | T::Unknown => None, + T::EVENT_EDIT => Some(Self::EventEdit), + T::MESSAGE_EDIT => Some(Self::MessageEdit), + T::POLL_EDIT => Some(Self::PollEdit), + T::POLL_ADD_OPTION => Some(Self::PollAddOption), + T::MESSAGE_SCHEDULE | T::UNKNOWN => None, } } @@ -323,27 +324,27 @@ impl<'a> SecretEncrypted<'a> { /// Returns `None` when the message is not secret-encrypted, carries an /// unsupported type, or is malformed (missing fields, IV not 12 bytes). pub fn extract_secret_encrypted(msg: &wa::Message) -> Option> { - if let Some(sec) = msg.secret_encrypted_message.as_ref() { - let kind = SecretEncKind::from_proto(sec.secret_enc_type())?; + if let Some(sec) = msg.secret_encrypted_message.as_option() { + let kind = SecretEncKind::from_proto(sec.secret_enc_type?)?; return secret_envelope( kind, - sec.target_message_key.as_ref(), + sec.target_message_key.as_option(), sec.enc_payload.as_deref(), sec.enc_iv.as_deref(), ); } - if let Some(enc) = msg.enc_reaction_message.as_ref() { + if let Some(enc) = msg.enc_reaction_message.as_option() { return secret_envelope( SecretEncKind::EncReaction, - enc.target_message_key.as_ref(), + enc.target_message_key.as_option(), enc.enc_payload.as_deref(), enc.enc_iv.as_deref(), ); } - if let Some(enc) = msg.enc_comment_message.as_ref() { + if let Some(enc) = msg.enc_comment_message.as_option() { return secret_envelope( SecretEncKind::EncComment, - enc.target_message_key.as_ref(), + enc.target_message_key.as_option(), enc.enc_payload.as_deref(), enc.enc_iv.as_deref(), ); @@ -405,7 +406,7 @@ pub fn decrypt_secret_encrypted( &sender, )?; Ok(wa::Message { - reaction_message: Some(Box::new(reaction)), + reaction_message: MessageField::some(reaction), ..Default::default() }) } @@ -541,21 +542,21 @@ mod tests { fn inner(text: &str) -> wa::Message { wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage { - key: Some(wa::MessageKey { + protocol_message: MessageField::some(wa::message::ProtocolMessage { + key: MessageField::some(wa::MessageKey { remote_jid: Some("123@s.whatsapp.net".to_string()), from_me: Some(false), id: Some("AC1".to_string()), participant: None, }), - r#type: Some(wa::message::protocol_message::Type::MessageEdit as i32), - edited_message: Some(Box::new(wa::Message { + r#type: Some(wa::message::protocol_message::Type::MESSAGE_EDIT), + edited_message: MessageField::some(wa::Message { conversation: Some(text.to_string()), ..Default::default() - })), + }), timestamp_ms: Some(1_700_000_000_000), ..Default::default() - })), + }), ..Default::default() } } @@ -576,8 +577,8 @@ mod tests { let m = decrypt(&enc, &iv, &secret, "AC1", &with_device, &with_device).unwrap(); assert_eq!( m.protocol_message - .as_ref() - .and_then(|pm| pm.edited_message.as_ref()) + .as_option() + .and_then(|pm| pm.edited_message.as_option()) .and_then(|e| e.conversation.as_deref()), Some("hi") ); @@ -586,8 +587,8 @@ mod tests { #[test] fn extract_envelope_recognises_message_edit() { let msg = wa::Message { - secret_encrypted_message: Some(Box::new(wa::message::SecretEncryptedMessage { - target_message_key: Some(wa::MessageKey { + secret_encrypted_message: MessageField::some(wa::message::SecretEncryptedMessage { + target_message_key: MessageField::some(wa::MessageKey { remote_jid: Some("g@g.us".to_string()), from_me: Some(false), id: Some("AC1".to_string()), @@ -596,10 +597,10 @@ mod tests { enc_payload: Some(vec![0u8; 32]), enc_iv: Some(vec![0u8; 12]), secret_enc_type: Some( - wa::message::secret_encrypted_message::SecretEncType::MessageEdit as i32, + wa::message::secret_encrypted_message::SecretEncType::MESSAGE_EDIT, ), remote_key_id: None, - })), + }), ..Default::default() }; let env = extract_envelope(&msg).expect("recognised"); @@ -615,8 +616,8 @@ mod tests { #[test] fn original_sender_jid_uses_my_jid_for_self_sent_edits() { let msg = wa::Message { - secret_encrypted_message: Some(Box::new(wa::message::SecretEncryptedMessage { - target_message_key: Some(wa::MessageKey { + secret_encrypted_message: MessageField::some(wa::message::SecretEncryptedMessage { + target_message_key: MessageField::some(wa::MessageKey { remote_jid: Some("5510000@s.whatsapp.net".to_string()), from_me: Some(true), id: Some("AC1".to_string()), @@ -625,10 +626,10 @@ mod tests { enc_payload: Some(vec![0u8; 32]), enc_iv: Some(vec![0u8; 12]), secret_enc_type: Some( - wa::message::secret_encrypted_message::SecretEncType::MessageEdit as i32, + wa::message::secret_encrypted_message::SecretEncType::MESSAGE_EDIT, ), remote_key_id: None, - })), + }), ..Default::default() }; let env = extract_envelope(&msg).expect("recognised"); @@ -647,8 +648,8 @@ mod tests { // editor's frame (from_me=true), so the receive path uses the envelope // sender via `original_sender_for_dispatch`, not this resolver. let msg = wa::Message { - secret_encrypted_message: Some(Box::new(wa::message::SecretEncryptedMessage { - target_message_key: Some(wa::MessageKey { + secret_encrypted_message: MessageField::some(wa::message::SecretEncryptedMessage { + target_message_key: MessageField::some(wa::MessageKey { remote_jid: Some("5510000@s.whatsapp.net".to_string()), from_me: Some(false), id: Some("AC1".to_string()), @@ -657,10 +658,10 @@ mod tests { enc_payload: Some(vec![0u8; 32]), enc_iv: Some(vec![0u8; 12]), secret_enc_type: Some( - wa::message::secret_encrypted_message::SecretEncType::MessageEdit as i32, + wa::message::secret_encrypted_message::SecretEncType::MESSAGE_EDIT, ), remote_key_id: None, - })), + }), ..Default::default() }; let env = extract_envelope(&msg).expect("recognised"); @@ -676,20 +677,22 @@ mod tests { // The MESSAGE_EDIT-specific consumer API resolves from the envelope // frame, ignoring the editor-framed target key (here from_me=true). let msg = wa::Message { - secret_encrypted_message: Some(Box::new(wa::message::SecretEncryptedMessage { - target_message_key: Some(wa::MessageKey { - remote_jid: Some("100000000000001@lid".to_string()), - from_me: Some(true), - id: Some("AC1".to_string()), - participant: None, - }), - enc_payload: Some(vec![0u8; 32]), - enc_iv: Some(vec![0u8; 12]), - secret_enc_type: Some( - wa::message::secret_encrypted_message::SecretEncType::MessageEdit as i32, - ), - remote_key_id: None, - })), + secret_encrypted_message: buffa::MessageField::some( + wa::message::SecretEncryptedMessage { + target_message_key: buffa::MessageField::some(wa::MessageKey { + remote_jid: Some("100000000000001@lid".to_string()), + from_me: Some(true), + id: Some("AC1".to_string()), + participant: None, + }), + enc_payload: Some(vec![0u8; 32]), + enc_iv: Some(vec![0u8; 12]), + secret_enc_type: Some( + wa::message::secret_encrypted_message::SecretEncType::MESSAGE_EDIT, + ), + remote_key_id: None, + }, + ), ..Default::default() }; let env = extract_envelope(&msg).expect("recognised"); @@ -718,20 +721,22 @@ mod tests { // is always the author (you can only edit your own message), so the // sender must come from the envelope frame. let msg = wa::Message { - secret_encrypted_message: Some(Box::new(wa::message::SecretEncryptedMessage { - target_message_key: Some(wa::MessageKey { - remote_jid: Some("100000000000001@lid".to_string()), // our LID (editor's frame) - from_me: Some(true), - id: Some("AC1".to_string()), - participant: None, - }), - enc_payload: Some(vec![0u8; 32]), - enc_iv: Some(vec![0u8; 12]), - secret_enc_type: Some( - wa::message::secret_encrypted_message::SecretEncType::MessageEdit as i32, - ), - remote_key_id: None, - })), + secret_encrypted_message: buffa::MessageField::some( + wa::message::SecretEncryptedMessage { + target_message_key: buffa::MessageField::some(wa::MessageKey { + remote_jid: Some("100000000000001@lid".to_string()), // our LID (editor's frame) + from_me: Some(true), + id: Some("AC1".to_string()), + participant: None, + }), + enc_payload: Some(vec![0u8; 32]), + enc_iv: Some(vec![0u8; 12]), + secret_enc_type: Some( + wa::message::secret_encrypted_message::SecretEncType::MESSAGE_EDIT, + ), + remote_key_id: None, + }, + ), ..Default::default() }; let env = extract_secret_encrypted(&msg).expect("recognised"); @@ -751,20 +756,22 @@ mod tests { // Our own edit, synced from another linked device: the envelope IS from // me, so the original sender is us — device suffix stripped. let msg = wa::Message { - secret_encrypted_message: Some(Box::new(wa::message::SecretEncryptedMessage { - target_message_key: Some(wa::MessageKey { - remote_jid: Some("200000000000002@lid".to_string()), - from_me: Some(true), - id: Some("AC1".to_string()), - participant: None, - }), - enc_payload: Some(vec![0u8; 32]), - enc_iv: Some(vec![0u8; 12]), - secret_enc_type: Some( - wa::message::secret_encrypted_message::SecretEncType::MessageEdit as i32, - ), - remote_key_id: None, - })), + secret_encrypted_message: buffa::MessageField::some( + wa::message::SecretEncryptedMessage { + target_message_key: buffa::MessageField::some(wa::MessageKey { + remote_jid: Some("200000000000002@lid".to_string()), + from_me: Some(true), + id: Some("AC1".to_string()), + participant: None, + }), + enc_payload: Some(vec![0u8; 32]), + enc_iv: Some(vec![0u8; 12]), + secret_enc_type: Some( + wa::message::secret_encrypted_message::SecretEncType::MESSAGE_EDIT, + ), + remote_key_id: None, + }, + ), ..Default::default() }; let env = extract_secret_encrypted(&msg).expect("recognised"); @@ -784,20 +791,22 @@ mod tests { // other than the target's author (e.g. a peer votes on our poll), so the // target key stays authoritative for non-edit kinds. let msg = wa::Message { - secret_encrypted_message: Some(Box::new(wa::message::SecretEncryptedMessage { - target_message_key: Some(wa::MessageKey { - remote_jid: Some("g@g.us".to_string()), - from_me: Some(false), - id: Some("AC1".to_string()), - participant: Some("creator@s.whatsapp.net".to_string()), - }), - enc_payload: Some(vec![0u8; 32]), - enc_iv: Some(vec![0u8; 12]), - secret_enc_type: Some( - wa::message::secret_encrypted_message::SecretEncType::PollEdit as i32, - ), - remote_key_id: None, - })), + secret_encrypted_message: buffa::MessageField::some( + wa::message::SecretEncryptedMessage { + target_message_key: buffa::MessageField::some(wa::MessageKey { + remote_jid: Some("g@g.us".to_string()), + from_me: Some(false), + id: Some("AC1".to_string()), + participant: Some("creator@s.whatsapp.net".to_string()), + }), + enc_payload: Some(vec![0u8; 32]), + enc_iv: Some(vec![0u8; 12]), + secret_enc_type: Some( + wa::message::secret_encrypted_message::SecretEncType::POLL_EDIT, + ), + remote_key_id: None, + }, + ), ..Default::default() }; let env = extract_secret_encrypted(&msg).expect("recognised"); @@ -816,15 +825,15 @@ mod tests { #[test] fn extract_envelope_rejects_non_edit_secret_enc_type() { let msg = wa::Message { - secret_encrypted_message: Some(Box::new(wa::message::SecretEncryptedMessage { - target_message_key: Some(wa::MessageKey::default()), + secret_encrypted_message: MessageField::some(wa::message::SecretEncryptedMessage { + target_message_key: MessageField::some(wa::MessageKey::default()), enc_payload: Some(vec![0u8; 32]), enc_iv: Some(vec![0u8; 12]), secret_enc_type: Some( - wa::message::secret_encrypted_message::SecretEncType::EventEdit as i32, + wa::message::secret_encrypted_message::SecretEncType::EVENT_EDIT, ), remote_key_id: None, - })), + }), ..Default::default() }; assert!(extract_envelope(&msg).is_none()); @@ -833,15 +842,15 @@ mod tests { #[test] fn extract_envelope_rejects_invalid_iv_size() { let msg = wa::Message { - secret_encrypted_message: Some(Box::new(wa::message::SecretEncryptedMessage { - target_message_key: Some(wa::MessageKey::default()), + secret_encrypted_message: MessageField::some(wa::message::SecretEncryptedMessage { + target_message_key: MessageField::some(wa::MessageKey::default()), enc_payload: Some(vec![0u8; 32]), enc_iv: Some(vec![0u8; 11]), secret_enc_type: Some( - wa::message::secret_encrypted_message::SecretEncType::MessageEdit as i32, + wa::message::secret_encrypted_message::SecretEncType::MESSAGE_EDIT, ), remote_key_id: None, - })), + }), ..Default::default() }; assert!(extract_envelope(&msg).is_none()); @@ -889,20 +898,20 @@ mod tests { let rewrap = rewrap_as_legacy_edit(dec).expect("present"); let edited = rewrap .protocol_message - .as_ref() - .and_then(|pm| pm.edited_message.as_ref()) + .as_option() + .and_then(|pm| pm.edited_message.as_option()) .and_then(|m| m.conversation.as_deref()); assert_eq!(edited, Some("edited")); assert_eq!( - rewrap.protocol_message.as_ref().and_then(|pm| pm.r#type), - Some(wa::message::protocol_message::Type::MessageEdit as i32) + rewrap.protocol_message.as_option().and_then(|pm| pm.r#type), + Some(wa::message::protocol_message::Type::MESSAGE_EDIT) ); } #[test] fn rewrap_returns_none_when_inner_missing_edit() { let m = wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage::default())), + protocol_message: MessageField::some(wa::message::ProtocolMessage::default()), ..Default::default() }; assert!(rewrap_as_legacy_edit(m).is_none()); @@ -912,8 +921,8 @@ mod tests { fn secret_msg(enc_type: SecretEncType, payload: Vec, iv: Vec) -> wa::Message { wa::Message { - secret_encrypted_message: Some(Box::new(wa::message::SecretEncryptedMessage { - target_message_key: Some(wa::MessageKey { + secret_encrypted_message: MessageField::some(wa::message::SecretEncryptedMessage { + target_message_key: MessageField::some(wa::MessageKey { remote_jid: Some("5510000@s.whatsapp.net".to_string()), from_me: Some(false), id: Some("PARENT1".to_string()), @@ -921,9 +930,9 @@ mod tests { }), enc_payload: Some(payload), enc_iv: Some(iv), - secret_enc_type: Some(enc_type as i32), + secret_enc_type: Some(enc_type), remote_key_id: None, - })), + }), ..Default::default() } } @@ -931,10 +940,10 @@ mod tests { #[test] fn extract_secret_encrypted_recognises_all_supported_kinds() { for (t, k) in [ - (SecretEncType::EventEdit, SecretEncKind::EventEdit), - (SecretEncType::MessageEdit, SecretEncKind::MessageEdit), - (SecretEncType::PollEdit, SecretEncKind::PollEdit), - (SecretEncType::PollAddOption, SecretEncKind::PollAddOption), + (SecretEncType::EVENT_EDIT, SecretEncKind::EventEdit), + (SecretEncType::MESSAGE_EDIT, SecretEncKind::MessageEdit), + (SecretEncType::POLL_EDIT, SecretEncKind::PollEdit), + (SecretEncType::POLL_ADD_OPTION, SecretEncKind::PollAddOption), ] { let msg = secret_msg(t, vec![0u8; 32], vec![0u8; 12]); let env = extract_secret_encrypted(&msg).expect("recognised"); @@ -945,7 +954,7 @@ mod tests { #[test] fn extract_secret_encrypted_rejects_unsupported_kinds() { - for t in [SecretEncType::MessageSchedule, SecretEncType::Unknown] { + for t in [SecretEncType::MESSAGE_SCHEDULE, SecretEncType::UNKNOWN] { let msg = secret_msg(t, vec![0u8; 32], vec![0u8; 12]); assert!(extract_secret_encrypted(&msg).is_none()); } @@ -955,17 +964,17 @@ mod tests { fn extract_envelope_still_only_matches_message_edit() { // The MESSAGE_EDIT-specific helper must ignore other kinds even though // the general extractor accepts them. - let poll = secret_msg(SecretEncType::PollEdit, vec![0u8; 32], vec![0u8; 12]); + let poll = secret_msg(SecretEncType::POLL_EDIT, vec![0u8; 32], vec![0u8; 12]); assert!(extract_envelope(&poll).is_none()); assert!(extract_secret_encrypted(&poll).is_some()); - let edit = secret_msg(SecretEncType::MessageEdit, vec![0u8; 32], vec![0u8; 12]); + let edit = secret_msg(SecretEncType::MESSAGE_EDIT, vec![0u8; 32], vec![0u8; 12]); assert!(extract_envelope(&edit).is_some()); } #[test] fn decrypt_secret_encrypted_roundtrip_poll_edit() { - use prost::Message as _; + use buffa::Message as _; use wacore::secret_enc_addon::{AddonContext, encrypt_addon}; let secret = [0x63u8; 32]; @@ -991,10 +1000,12 @@ mod tests { .unwrap(); let msg = { - let mut m = secret_msg(SecretEncType::PollEdit, enc, iv.to_vec()); + let mut m = secret_msg(SecretEncType::POLL_EDIT, enc, iv.to_vec()); // creator is the parent's remote_jid (1:1 incoming). - if let Some(sec) = m.secret_encrypted_message.as_mut() { - sec.target_message_key.as_mut().unwrap().remote_jid = Some(creator.to_string()); + if let Some(sec) = m.secret_encrypted_message.as_option_mut() + && let Some(key) = sec.target_message_key.as_option_mut() + { + key.remote_jid = Some(creator.to_string()); } m }; @@ -1033,11 +1044,11 @@ mod enc_addon_tests { #[test] fn extract_recognises_enc_reaction_and_comment_envelopes() { let reaction = wa::Message { - enc_reaction_message: Some(Box::new(wa::message::EncReactionMessage { - target_message_key: Some(key("PARENT1")), + enc_reaction_message: MessageField::some(wa::message::EncReactionMessage { + target_message_key: MessageField::some(key("PARENT1")), enc_payload: Some(vec![0; 32]), enc_iv: Some(vec![0; 12]), - })), + }), ..Default::default() }; let env = extract_secret_encrypted(&reaction).expect("reaction recognised"); @@ -1045,11 +1056,11 @@ mod enc_addon_tests { assert_eq!(env.target_id(), Some("PARENT1")); let comment = wa::Message { - enc_comment_message: Some(Box::new(wa::message::EncCommentMessage { - target_message_key: Some(key("PARENT2")), + enc_comment_message: MessageField::some(wa::message::EncCommentMessage { + target_message_key: MessageField::some(key("PARENT2")), enc_payload: Some(vec![0; 32]), enc_iv: Some(vec![0; 12]), - })), + }), ..Default::default() }; let env = extract_secret_encrypted(&comment).expect("comment recognised"); @@ -1060,21 +1071,21 @@ mod enc_addon_tests { #[test] fn extract_rejects_malformed_enc_reaction_envelope() { let bad_iv = wa::Message { - enc_reaction_message: Some(Box::new(wa::message::EncReactionMessage { - target_message_key: Some(key("PARENT1")), + enc_reaction_message: MessageField::some(wa::message::EncReactionMessage { + target_message_key: MessageField::some(key("PARENT1")), enc_payload: Some(vec![0; 32]), enc_iv: Some(vec![0; 8]), - })), + }), ..Default::default() }; assert!(extract_secret_encrypted(&bad_iv).is_none()); let no_key = wa::Message { - enc_reaction_message: Some(Box::new(wa::message::EncReactionMessage { - target_message_key: None, + enc_reaction_message: MessageField::some(wa::message::EncReactionMessage { + target_message_key: MessageField::none(), enc_payload: Some(vec![0; 32]), enc_iv: Some(vec![0; 12]), - })), + }), ..Default::default() }; assert!(extract_secret_encrypted(&no_key).is_none()); @@ -1111,7 +1122,7 @@ mod enc_addon_tests { None, ) .expect("fallback identity must decrypt"); - let rm = out.reaction_message.expect("reaction shape"); + let rm = out.reaction_message.into_option().expect("reaction shape"); assert_eq!(rm.text.as_deref(), Some("\u{2764}")); // Without a distinct fallback the primary error surfaces. @@ -1156,7 +1167,7 @@ mod enc_addon_tests { .expect("primary-author + fallback-modifier combination must decrypt"); assert_eq!( out.reaction_message - .as_ref() + .as_option() .and_then(|r| r.text.as_deref()), Some("\u{1F44D}") ); @@ -1168,10 +1179,10 @@ mod enc_addon_tests { let author: Jid = "5511000000001@s.whatsapp.net".parse().unwrap(); let commenter: Jid = "5511000000002@s.whatsapp.net".parse().unwrap(); let body = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: MessageField::some(wa::message::ExtendedTextMessage { text: Some("hi".to_string()), ..Default::default() - })), + }), ..Default::default() }; let (enc, iv) = wacore::comment::encrypt_comment_with_secret( @@ -1195,7 +1206,7 @@ mod enc_addon_tests { .expect("comment decrypts"); assert_eq!( out.extended_text_message - .as_ref() + .as_option() .and_then(|m| m.text.as_deref()), Some("hi") ); diff --git a/src/features/polls.rs b/src/features/polls.rs index 81145e80c..236ce3347 100644 --- a/src/features/polls.rs +++ b/src/features/polls.rs @@ -89,12 +89,12 @@ impl<'a> Polls<'a> { // WA Web: v3 for single-select, v1 for multi-select (GeneratePollCreationMessageProto.js:39-41) let mut message = if selectable_count == 1 { wa::Message { - poll_creation_message_v3: Some(Box::new(poll_msg)), + poll_creation_message_v3: buffa::MessageField::some(poll_msg), ..Default::default() } } else { wa::Message { - poll_creation_message: Some(Box::new(poll_msg)), + poll_creation_message: buffa::MessageField::some(poll_msg), ..Default::default() } }; @@ -108,10 +108,10 @@ impl<'a> Polls<'a> { secret }; - message.message_context_info = Some(Box::new(wa::MessageContextInfo { + message.message_context_info = buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(message_secret.clone()), ..Default::default() - })); + }); let result = self.client.send_message(to, message).await?; Ok((result, message_secret)) @@ -152,7 +152,7 @@ impl<'a> Polls<'a> { let from_me = my_base.is_same_user_as(poll_creator_jid); let poll_update = wa::message::PollUpdateMessage { - poll_creation_message_key: Some(wa::MessageKey { + poll_creation_message_key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some(chat_jid.to_string()), from_me: Some(from_me), id: Some(poll_msg_id.to_string()), @@ -162,18 +162,18 @@ impl<'a> Polls<'a> { None }, }), - vote: Some(wa::message::PollEncValue { + vote: buffa::MessageField::some(wa::message::PollEncValue { enc_payload: Some(enc_payload), enc_iv: Some(iv.to_vec()), }), // WA Web's GeneratePollVoteMessageProto never sets metadata; a Some(empty) // submessage emits a stray `1A 00` (tag 3) on the wire. Omit it. - metadata: None, + metadata: buffa::MessageField::none(), sender_timestamp_ms: Some(wacore::time::now_millis()), }; let message = wa::Message { - poll_update_message: Some(Box::new(poll_update)), + poll_update_message: buffa::MessageField::some(poll_update), ..Default::default() }; @@ -286,9 +286,9 @@ impl<'a> Polls<'a> { let creator_str = creator.to_string(); let creator_alt = self.swapped_user(&creator).await; - // Keyed by canonical (LID-preferred) identity; value holds the - // as-received JID for the reported voters list. Last-vote-wins. - let mut latest_votes: HashMap>)> = + // Keyed by canonical (LID-preferred) identity. The optional display JID + // is only stored when it differs from the key. Last-vote-wins. + let mut latest_votes: HashMap, Vec)> = HashMap::with_capacity(votes.len()); for (voter_jid, ciphertext) in votes { let voter = voter_jid.to_non_ad(); @@ -310,11 +310,26 @@ impl<'a> Polls<'a> { }, fallback, ) { - Ok(selected_hashes) => { - if selected_hashes.is_empty() { - latest_votes.remove(&canonical_voter); + Ok(hashes) => { + let display_jid = if voter.is_lid() { + None + } else if voter_alt.is_some() { + Some(voter_str) } else { - latest_votes.insert(canonical_voter, (voter_str, selected_hashes)); + None + }; + if hashes.is_empty() { + latest_votes.remove(canonical_voter.as_str()); + } else { + let selected_indices: Vec = hashes + .iter() + .filter_map(|h| { + <[u8; 32]>::try_from(h.as_slice()).ok().and_then(|arr| { + option_hashes.iter().position(|(oh, _)| *oh == arr) + }) + }) + .collect(); + latest_votes.insert(canonical_voter, (display_jid, selected_indices)); } } Err(e) => { @@ -331,13 +346,13 @@ impl<'a> Polls<'a> { }) .collect(); - for (display_jid, selected_hashes) in latest_votes.values() { - for hash in selected_hashes { - if let Ok(hash_arr) = <[u8; 32]>::try_from(hash.as_slice()) - && let Some(idx) = option_hashes.iter().position(|(h, _)| *h == hash_arr) - { - results[idx].voters.push(display_jid.clone()); + for (canonical_jid, (display_jid, selected_indices)) in latest_votes { + let display_jid = display_jid.unwrap_or(canonical_jid); + if let Some((last_idx, prefix_indices)) = selected_indices.split_last() { + for idx in prefix_indices { + results[*idx].voters.push(display_jid.clone()); } + results[*last_idx].voters.push(display_jid); } } @@ -403,9 +418,12 @@ fn build_poll_creation_message( // createOptionHashHexFromString (the proto field is a string, not bytes). option_hash: Some(hex::encode(poll::compute_option_hash(correct))), }; - (Some(wa::message::PollType::Quiz as i32), Some(answer)) + ( + Some(wa::message::PollType::QUIZ), + buffa::MessageField::some(answer), + ) } - None => (None, None), + None => (None, buffa::MessageField::none()), }; let poll_options: Vec = options @@ -421,11 +439,11 @@ fn build_poll_creation_message( name: Some(name.to_string()), options: poll_options, selectable_options_count: Some(selectable_count), - context_info: None, + context_info: buffa::MessageField::none(), // WA Web's GeneratePollCreationMessageProto always sets pollContentType // (TEXT=1 for a normal poll); omitting it drops a field the real client // always emits. - poll_content_type: Some(wa::message::PollContentType::Text as i32), + poll_content_type: Some(wa::message::PollContentType::TEXT), poll_type, correct_answer, ..Default::default() @@ -447,11 +465,11 @@ mod tests { let options = vec!["A".to_string(), "B".to_string(), "C".to_string()]; let msg = build_poll_creation_message("Q?", &options, 2, None).unwrap(); assert_eq!(msg.poll_type, None); - assert!(msg.correct_answer.is_none()); + assert!(msg.correct_answer.is_unset()); assert_eq!(msg.selectable_options_count, Some(2)); assert_eq!( msg.poll_content_type, - Some(wa::message::PollContentType::Text as i32) + Some(wa::message::PollContentType::TEXT) ); assert_eq!(msg.options.len(), 3); assert!(msg.options.iter().all(|o| o.option_hash.is_none())); @@ -461,9 +479,10 @@ mod tests { fn quiz_sets_poll_type_and_correct_answer() { let options = vec!["A".to_string(), "B".to_string(), "C".to_string()]; let msg = build_poll_creation_message("Q?", &options, 1, Some(1)).unwrap(); - assert_eq!(msg.poll_type, Some(wa::message::PollType::Quiz as i32)); + assert_eq!(msg.poll_type, Some(wa::message::PollType::QUIZ)); let answer = msg .correct_answer + .as_option() .expect("quiz must carry a correct answer"); // WA Web sets BOTH name and hash on the chosen option, even for text polls; // the hash is the lowercase hex of SHA-256(name). diff --git a/src/features/profile.rs b/src/features/profile.rs index b90ade18a..c895ab9b0 100644 --- a/src/features/profile.rs +++ b/src/features/profile.rs @@ -148,7 +148,7 @@ impl<'a> Profile<'a> { use waproto::whatsapp as wa; let value = wa::SyncActionValue { - push_name_setting: Some(wa::sync_action_value::PushNameSetting { + push_name_setting: buffa::MessageField::some(wa::sync_action_value::PushNameSetting { name: Some(name.to_string()), }), timestamp: Some(wacore::time::now_millis()), diff --git a/src/features/reaction.rs b/src/features/reaction.rs index b31aeda56..09d82241d 100644 --- a/src/features/reaction.rs +++ b/src/features/reaction.rs @@ -8,6 +8,7 @@ //! message's `messageSecret` and emits an `enc_reaction_message` envelope. //! [`Client::send_reaction`] applies the same gate transparently. +use buffa::MessageField; use wacore_binary::{Jid, JidExt}; use waproto::whatsapp as wa; @@ -102,11 +103,11 @@ impl Client { } let message = wa::Message { - enc_reaction_message: Some(Box::new(wa::message::EncReactionMessage { - target_message_key: Some(target_key), + enc_reaction_message: MessageField::some(wa::message::EncReactionMessage { + target_message_key: MessageField::some(target_key), enc_payload: Some(enc_payload), enc_iv: Some(iv.to_vec()), - })), + }), ..Default::default() }; self.send_message(chat, message).await diff --git a/src/features/status.rs b/src/features/status.rs index 7827bdd13..f1714c9a2 100644 --- a/src/features/status.rs +++ b/src/features/status.rs @@ -43,22 +43,23 @@ impl<'a> Status<'a> { /// Send a text status update to the given recipients. /// /// `background_argb` is the background color as 0xAARRGGBB (e.g., `0xFF1E6E4F`). - /// `font` is the font style index (0-4 on WhatsApp Web). + /// `font` selects the status font; values outside the protocol enum can't be + /// passed (the prior `i32` form silently dropped them at encode time). pub async fn send_text( &self, text: &str, background_argb: u32, - font: i32, + font: wa::message::extended_text_message::FontType, recipients: &[Jid], options: StatusSendOptions, ) -> Result { let message = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some(text.to_string()), background_argb: Some(background_argb), font: Some(font), ..Default::default() - })), + }), ..Default::default() }; @@ -149,16 +150,16 @@ impl<'a> Status<'a> { let to = Jid::status_broadcast(); let revoke_message = wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage { - key: Some(wa::MessageKey { + protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage { + key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some(to.to_string()), from_me: Some(true), id: Some(message_id), - participant: None, + ..Default::default() }), - r#type: Some(wa::message::protocol_message::Type::Revoke as i32), + r#type: Some(wa::message::protocol_message::Type::REVOKE), ..Default::default() - })), + }), ..Default::default() }; @@ -174,10 +175,11 @@ impl Client { /// # Example /// ```no_run /// # async fn example(client: &whatsapp_rust::Client) -> anyhow::Result<()> { + /// use waproto::whatsapp::message::extended_text_message::FontType; /// let recipients = [whatsapp_rust::Jid::pn("15551234567")]; /// let id = client /// .status() - /// .send_text("Hello!", 0xFF1E6E4F, 0, &recipients, Default::default()) + /// .send_text("Hello!", 0xFF1E6E4F, FontType::SYSTEM, &recipients, Default::default()) /// .await?; /// # Ok(()) /// # } @@ -214,23 +216,24 @@ mod tests { #[test] fn test_status_text_message_structure() { // Verify the message structure matches WhatsApp Web's extendedTextMessage format + use waproto::whatsapp::message::extended_text_message::FontType; let text = "Hello from Rust!"; let bg = 0xFF1E6E4F_u32; - let font = 2_i32; + let font = FontType::FB_SCRIPT; let message = waproto::whatsapp::Message { - extended_text_message: Some(Box::new( + extended_text_message: buffa::MessageField::some( waproto::whatsapp::message::ExtendedTextMessage { text: Some(text.to_string()), background_argb: Some(bg), font: Some(font), ..Default::default() }, - )), + ), ..Default::default() }; - let ext = message.extended_text_message.as_ref().unwrap(); + let ext = message.extended_text_message.as_option().unwrap(); assert_eq!(ext.text.as_deref(), Some(text)); assert_eq!(ext.background_argb, Some(bg)); assert_eq!(ext.font, Some(font)); @@ -244,25 +247,23 @@ mod tests { let to = Jid::status_broadcast(); let revoke_message = wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage { - key: Some(wa::MessageKey { + protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage { + key: wa::MessageKey { remote_jid: Some(to.to_string()), from_me: Some(true), id: Some(original_id.to_string()), - participant: None, - }), - r#type: Some(wa::message::protocol_message::Type::Revoke as i32), + ..Default::default() + } + .into(), + r#type: Some(wa::message::protocol_message::Type::REVOKE), ..Default::default() - })), + }), ..Default::default() }; - let pm = revoke_message.protocol_message.as_ref().unwrap(); - assert_eq!( - pm.r#type, - Some(wa::message::protocol_message::Type::Revoke as i32) - ); - let key = pm.key.as_ref().unwrap(); + let pm = revoke_message.protocol_message.as_option().unwrap(); + assert_eq!(pm.r#type, Some(wa::message::protocol_message::Type::REVOKE)); + let key = pm.key.as_option().unwrap(); assert_eq!(key.remote_jid.as_deref(), Some("status@broadcast")); assert_eq!(key.from_me, Some(true)); assert_eq!(key.id.as_deref(), Some(original_id)); @@ -274,28 +275,30 @@ mod tests { // Non-revoke message let text_msg = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("hello".to_string()), ..Default::default() - })), + }), ..Default::default() }; - let is_revoke = text_msg.protocol_message.as_ref().is_some_and(|pm| { - pm.r#type == Some(wa::message::protocol_message::Type::Revoke as i32) - }); + let is_revoke = text_msg + .protocol_message + .as_option() + .is_some_and(|pm| pm.r#type == Some(wa::message::protocol_message::Type::REVOKE)); assert!(!is_revoke, "text message should not be detected as revoke"); // Revoke message let revoke_msg = wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage { - r#type: Some(wa::message::protocol_message::Type::Revoke as i32), + protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage { + r#type: Some(wa::message::protocol_message::Type::REVOKE), ..Default::default() - })), + }), ..Default::default() }; - let is_revoke = revoke_msg.protocol_message.as_ref().is_some_and(|pm| { - pm.r#type == Some(wa::message::protocol_message::Type::Revoke as i32) - }); + let is_revoke = revoke_msg + .protocol_message + .as_option() + .is_some_and(|pm| pm.r#type == Some(wa::message::protocol_message::Type::REVOKE)); assert!(is_revoke, "revoke message should be detected as revoke"); } } diff --git a/src/handshake.rs b/src/handshake.rs index 4a104b533..5c4ce5c50 100644 --- a/src/handshake.rs +++ b/src/handshake.rs @@ -1,8 +1,8 @@ use crate::socket::NoiseSocket; use crate::store::persistence_manager::PersistenceManager; use crate::transport::{Transport, TransportEvent}; +use buffa::Message; use log::{debug, info, warn}; -use prost::Message; use std::sync::Arc; use std::sync::atomic::{AtomicU32, Ordering}; use std::time::Duration; @@ -92,8 +92,7 @@ impl HandshakeError { // inputs, HKDF can't reasonably fail, and a single handshake // can't exhaust the counter. None of these mean the cache // is bad. - Core::Proto(_) - | Core::Crypto(_) + Core::Crypto(_) | Core::Noise(NoiseError::Encrypt(_)) | Core::Noise(NoiseError::HkdfExpandFailed) | Core::Noise(NoiseError::InvalidPatternLength { .. }) diff --git a/src/history_sync.rs b/src/history_sync.rs index d9b61628e..b29199cf7 100644 --- a/src/history_sync.rs +++ b/src/history_sync.rs @@ -18,7 +18,7 @@ impl Client { log::debug!( "Dropping history sync {} during shutdown (Type: {:?})", message_id, - notification.sync_type() + notification.sync_type ); return; } @@ -27,7 +27,7 @@ impl Client { log::debug!( "Skipping history sync for message {} (Type: {:?})", message_id, - notification.sync_type() + notification.sync_type ); // Send receipt so the phone considers this chunk delivered and stops // retrying. This intentionally diverges from WhatsApp Web's AB prop @@ -75,8 +75,8 @@ impl Client { log::info!( "Processing history sync for message {} (Size: {}, Type: {:?})", message_id, - notification.file_length(), - notification.sync_type() + notification.file_length.unwrap_or(0), + notification.sync_type ); self.send_protocol_receipt( @@ -220,7 +220,7 @@ impl Client { let lazy_hs = LazyHistorySync::new( compressed, sync_result.decompressed_size, - notification.sync_type().into(), + notification.sync_type.map(|t| t as i32).unwrap_or(0), notification.chunk_order, notification.progress, ) @@ -489,8 +489,8 @@ fn push_unique_sender(senders: &mut Vec, sender: Jid) { #[cfg(test)] mod tests { use super::*; + use buffa::Message as ProtoMessage; use flate2::{Compression, write::ZlibEncoder}; - use prost::Message as ProtoMessage; use std::io::Write; use std::sync::atomic::Ordering; use waproto::whatsapp as wa; @@ -517,24 +517,24 @@ mod tests { let parent_id = "HIST_PARENT"; let secret = vec![0x44u8; 32]; let history_sync = wa::HistorySync { - sync_type: wa::history_sync::HistorySyncType::InitialBootstrap as i32, + sync_type: wa::history_sync::HistorySyncType::INITIAL_BOOTSTRAP, conversations: vec![wa::Conversation { id: chat.to_string(), messages: vec![wa::HistorySyncMsg { - message: Some(Box::new(wa::WebMessageInfo { - key: wa::MessageKey { + message: buffa::MessageField::some(wa::WebMessageInfo { + key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some(chat.to_string()), from_me: Some(false), id: Some(parent_id.to_string()), participant: None, - }, - message: Some(Box::new(wa::Message { + }), + message: buffa::MessageField::some(wa::Message { conversation: Some("historical".to_string()), ..Default::default() - })), + }), message_secret: Some(secret.clone()), ..Default::default() - })), + }), msg_order_id: Some(1), }], ..Default::default() @@ -544,7 +544,7 @@ mod tests { let compressed = compress_history_sync(&history_sync); let notification = HistorySyncNotification { file_length: Some(compressed.len() as u64), - sync_type: Some(wa::message::HistorySyncType::InitialBootstrap as i32), + sync_type: Some(wa::message::HistorySyncType::INITIAL_BOOTSTRAP), initial_hist_bootstrap_inline_payload: Some(compressed), ..Default::default() }; @@ -569,7 +569,7 @@ mod tests { let chat = "5511777776666@s.whatsapp.net"; let history_sync = wa::HistorySync { - sync_type: wa::history_sync::HistorySyncType::InitialBootstrap as i32, + sync_type: wa::history_sync::HistorySyncType::INITIAL_BOOTSTRAP, conversations: vec![wa::Conversation { id: chat.to_string(), ..Default::default() @@ -581,7 +581,7 @@ mod tests { let compressed_copy = compressed.clone(); let notification = HistorySyncNotification { file_length: Some(compressed.len() as u64), - sync_type: Some(wa::message::HistorySyncType::InitialBootstrap as i32), + sync_type: Some(wa::message::HistorySyncType::INITIAL_BOOTSTRAP), initial_hist_bootstrap_inline_payload: Some(compressed), ..Default::default() }; @@ -629,24 +629,24 @@ mod tests { let parent_id = "HIST_BOT_PARENT"; let secret = vec![0x61u8; 32]; let history_sync = wa::HistorySync { - sync_type: wa::history_sync::HistorySyncType::InitialBootstrap as i32, + sync_type: wa::history_sync::HistorySyncType::INITIAL_BOOTSTRAP, conversations: vec![wa::Conversation { id: chat.to_string(), messages: vec![wa::HistorySyncMsg { - message: Some(Box::new(wa::WebMessageInfo { - key: wa::MessageKey { + message: buffa::MessageField::some(wa::WebMessageInfo { + key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some(chat.to_string()), from_me: Some(false), id: Some(parent_id.to_string()), participant: None, - }, - message: Some(Box::new(wa::Message { + }), + message: buffa::MessageField::some(wa::Message { conversation: Some("bot historical".to_string()), ..Default::default() - })), + }), message_secret: Some(secret.clone()), ..Default::default() - })), + }), msg_order_id: Some(1), }], ..Default::default() @@ -656,7 +656,7 @@ mod tests { let compressed = compress_history_sync(&history_sync); let notification = HistorySyncNotification { file_length: Some(compressed.len() as u64), - sync_type: Some(wa::message::HistorySyncType::InitialBootstrap as i32), + sync_type: Some(wa::message::HistorySyncType::INITIAL_BOOTSTRAP), initial_hist_bootstrap_inline_payload: Some(compressed), ..Default::default() }; @@ -687,7 +687,9 @@ mod tests { ) -> wa::HistorySyncMsg { let message = if is_poll { wa::Message { - poll_creation_message: Some(Box::new(wa::message::PollCreationMessage::default())), + poll_creation_message: buffa::MessageField::some( + wa::message::PollCreationMessage::default(), + ), ..Default::default() } } else { @@ -697,18 +699,18 @@ mod tests { } }; wa::HistorySyncMsg { - message: Some(Box::new(wa::WebMessageInfo { - key: wa::MessageKey { + message: buffa::MessageField::some(wa::WebMessageInfo { + key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some(chat.to_string()), from_me: Some(false), id: Some(msg_id.to_string()), participant: None, - }, - message: Some(Box::new(message)), + }), + message: buffa::MessageField::some(message), message_secret: Some(secret.to_vec()), message_timestamp: Some(ts_secs), ..Default::default() - })), + }), msg_order_id: Some(1), } } @@ -718,7 +720,7 @@ mod tests { messages: Vec, ) -> HistorySyncNotification { let history_sync = wa::HistorySync { - sync_type: wa::history_sync::HistorySyncType::InitialBootstrap as i32, + sync_type: wa::history_sync::HistorySyncType::INITIAL_BOOTSTRAP, conversations: vec![wa::Conversation { id: chat.to_string(), messages, @@ -729,7 +731,7 @@ mod tests { let compressed = compress_history_sync(&history_sync); HistorySyncNotification { file_length: Some(compressed.len() as u64), - sync_type: Some(wa::message::HistorySyncType::InitialBootstrap as i32), + sync_type: Some(wa::message::HistorySyncType::INITIAL_BOOTSTRAP), initial_hist_bootstrap_inline_payload: Some(compressed), ..Default::default() } @@ -979,35 +981,35 @@ mod tests { ts_secs: u64, bot_prompt: bool, ) -> wa::HistorySyncMsg { - let message_context_info = bot_prompt.then(|| { - Box::new(wa::MessageContextInfo { - bot_metadata: Some(wa::BotMetadata { - persona_id: Some("867051314767696".into()), - ..Default::default() - }), + let message_context_info = bot_prompt.then(|| wa::MessageContextInfo { + bot_metadata: buffa::MessageField::some(wa::BotMetadata { + persona_id: Some("867051314767696".into()), ..Default::default() - }) + }), + ..Default::default() }); wa::HistorySyncMsg { - message: Some(Box::new(wa::WebMessageInfo { - key: wa::MessageKey { + message: buffa::MessageField::some(wa::WebMessageInfo { + key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some(chat.to_string()), from_me: Some(false), id: Some(msg_id.to_string()), participant: Some(participant.to_string()), - }, - message: Some(Box::new(wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { - text: Some("hi".into()), - ..Default::default() - })), - message_context_info, + }), + message: buffa::MessageField::some(wa::Message { + extended_text_message: buffa::MessageField::some( + wa::message::ExtendedTextMessage { + text: Some("hi".into()), + ..Default::default() + }, + ), + message_context_info: message_context_info.into(), ..Default::default() - })), + }), message_secret: Some(secret.to_vec()), message_timestamp: Some(ts_secs), ..Default::default() - })), + }), msg_order_id: Some(1), } } diff --git a/src/media.rs b/src/media.rs index 8713631b4..b7acd2d53 100644 --- a/src/media.rs +++ b/src/media.rs @@ -66,7 +66,7 @@ pub struct AudioOptions { /// Build an image message from an upload result. pub fn image_message(upload: UploadResponse, opts: ImageOptions) -> wa::Message { wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage { + image_message: buffa::MessageField::some(wa::message::ImageMessage { url: Some(upload.url), direct_path: Some(upload.direct_path), media_key: Some(upload.media_key.to_vec()), @@ -77,9 +77,12 @@ pub fn image_message(upload: UploadResponse, opts: ImageOptions) -> wa::Message mimetype: Some(opts.mimetype.unwrap_or_else(|| "image/jpeg".to_string())), caption: opts.caption, jpeg_thumbnail: opts.jpeg_thumbnail, - context_info: opts.context_info, + context_info: opts + .context_info + .map(|ci| buffa::MessageField::some(*ci)) + .unwrap_or_default(), ..Default::default() - })), + }), ..Default::default() } } @@ -88,7 +91,7 @@ pub fn image_message(upload: UploadResponse, opts: ImageOptions) -> wa::Message /// (progressive-playback HMAC table) from the upload when present. pub fn video_message(upload: UploadResponse, opts: VideoOptions) -> wa::Message { wa::Message { - video_message: Some(Box::new(wa::message::VideoMessage { + video_message: buffa::MessageField::some(wa::message::VideoMessage { url: Some(upload.url), direct_path: Some(upload.direct_path), media_key: Some(upload.media_key.to_vec()), @@ -102,9 +105,12 @@ pub fn video_message(upload: UploadResponse, opts: VideoOptions) -> wa::Message jpeg_thumbnail: opts.jpeg_thumbnail, seconds: opts.duration_seconds, gif_playback: opts.gif_playback, - context_info: opts.context_info, + context_info: opts + .context_info + .map(|ci| buffa::MessageField::some(*ci)) + .unwrap_or_default(), ..Default::default() - })), + }), ..Default::default() } } @@ -112,7 +118,7 @@ pub fn video_message(upload: UploadResponse, opts: VideoOptions) -> wa::Message /// Build a document message from an upload result. pub fn document_message(upload: UploadResponse, opts: DocumentOptions) -> wa::Message { wa::Message { - document_message: Some(Box::new(wa::message::DocumentMessage { + document_message: buffa::MessageField::some(wa::message::DocumentMessage { url: Some(upload.url), direct_path: Some(upload.direct_path), media_key: Some(upload.media_key.to_vec()), @@ -129,9 +135,12 @@ pub fn document_message(upload: UploadResponse, opts: DocumentOptions) -> wa::Me caption: opts.caption, page_count: opts.page_count, jpeg_thumbnail: opts.jpeg_thumbnail, - context_info: opts.context_info, + context_info: opts + .context_info + .map(|ci| buffa::MessageField::some(*ci)) + .unwrap_or_default(), ..Default::default() - })), + }), ..Default::default() } } @@ -140,7 +149,7 @@ pub fn document_message(upload: UploadResponse, opts: DocumentOptions) -> wa::Me /// streaming sidecar from the upload when present. pub fn audio_message(upload: UploadResponse, opts: AudioOptions) -> wa::Message { wa::Message { - audio_message: Some(Box::new(wa::message::AudioMessage { + audio_message: buffa::MessageField::some(wa::message::AudioMessage { url: Some(upload.url), direct_path: Some(upload.direct_path), media_key: Some(upload.media_key.to_vec()), @@ -156,9 +165,12 @@ pub fn audio_message(upload: UploadResponse, opts: AudioOptions) -> wa::Message seconds: opts.duration_seconds, ptt: opts.ptt, waveform: opts.waveform, - context_info: opts.context_info, + context_info: opts + .context_info + .map(|ci| buffa::MessageField::some(*ci)) + .unwrap_or_default(), ..Default::default() - })), + }), ..Default::default() } } @@ -258,7 +270,7 @@ mod tests { let image = image_msg.image_message.unwrap(); - assert!(image.context_info.is_some()); + assert!(image.context_info.is_set()); } #[test] @@ -275,6 +287,6 @@ mod tests { let video = video_msg.video_message.unwrap(); - assert!(video.context_info.is_some()); + assert!(video.context_info.is_set()); } } diff --git a/src/message.rs b/src/message.rs index 7faea92cb..8018e50d7 100644 --- a/src/message.rs +++ b/src/message.rs @@ -2,8 +2,6 @@ use crate::client::Client; use crate::types::events::Event; use crate::types::message::MessageInfo; use log::{debug, warn}; -use prost::Message as ProtoMessage; - use std::sync::Arc; use wacore::libsignal::crypto::DecryptionError; use wacore::libsignal::protocol::SenderKeyDistributionMessage; diff --git a/src/message/dispatch.rs b/src/message/dispatch.rs index d9aeddfee..e28731140 100644 --- a/src/message/dispatch.rs +++ b/src/message/dispatch.rs @@ -31,8 +31,8 @@ impl Client { if decrypted.is_some() && let Some(target) = msg .enc_comment_message - .as_ref() - .and_then(|c| c.target_message_key.clone()) + .as_option() + .and_then(|c| c.target_message_key.as_option().cloned()) { Arc::make_mut(&mut info).comment_target = Some(target); } diff --git a/src/message/msg_secret.rs b/src/message/msg_secret.rs index a15261366..ed61ebd04 100644 --- a/src/message/msg_secret.rs +++ b/src/message/msg_secret.rs @@ -13,7 +13,7 @@ impl Client { ) { use wacore::proto_helpers::MessageExt; - let mci = msg.message_context_info.as_ref(); + let mci = msg.message_context_info.as_option(); let Some(secret_bytes) = mci.and_then(|m| m.message_secret.as_deref()) else { return; }; @@ -335,10 +335,10 @@ impl Client { // lives in the envelope, so the surfaced plaintext-shape reaction gets // it from there (parity with a plaintext reaction_message). if env.kind == SecretEncKind::EncReaction - && let Some(rm) = inner.reaction_message.as_mut() - && rm.key.is_none() + && let Some(rm) = inner.reaction_message.as_option_mut() + && rm.key.is_unset() { - rm.key = Some(env.target_message_key.clone()); + rm.key = buffa::MessageField::some(env.target_message_key.clone()); } // A comment's own messageSecret rides the OUTER envelope (WA Web puts @@ -349,12 +349,10 @@ impl Client { if env.kind == SecretEncKind::EncComment && let Some(outer_secret) = msg .message_context_info - .as_ref() + .as_option() .and_then(|m| m.message_secret.as_ref()) { - let inner_mci = inner - .message_context_info - .get_or_insert_with(Default::default); + let inner_mci = inner.message_context_info.get_or_insert_default(); if inner_mci.message_secret.is_none() { inner_mci.message_secret = Some(outer_secret.clone()); } @@ -380,7 +378,7 @@ impl Client { if let Some(secret_bytes) = inner .message_context_info - .as_ref() + .as_option() .and_then(|m| m.message_secret.as_deref()) { // The re-persisted secret keys the NEXT add-on. For the edit/poll @@ -466,12 +464,12 @@ impl Client { info: &Arc, payload: EncPayload, ) { - use prost::Message as _; + use buffa::Message as _; use wa::MessageSecretMessage; use wacore::bot_message::{BotMessageContext, decrypt_bot_message}; use wacore::protocol::nack::NackReason; - let ms_msg = match MessageSecretMessage::decode(&*payload.ciphertext) { + let ms_msg = match MessageSecretMessage::decode_from_slice(&payload.ciphertext) { Ok(m) => m, Err(e) => { log::warn!( diff --git a/src/message/receive.rs b/src/message/receive.rs index 465bf0b05..37e3f3d39 100644 --- a/src/message/receive.rs +++ b/src/message/receive.rs @@ -1331,7 +1331,7 @@ impl Client { // Validate DSM presence against sender identity // (WAWebHandleMsgError.DeviceSentMessageError) - if original_msg.device_sent_message.is_some() && !info.source.is_from_me { + if original_msg.device_sent_message.is_set() && !info.source.is_from_me { warn!( "[msg:{}] DeviceSentMessage present but sender {} is not self", info.id, @@ -1343,7 +1343,7 @@ impl Client { // phashV2 of the broadcast recipients in deviceSentMessage.phash. // Recompute over our view and warn on divergence. We log // only (no drop) until the participant hash form is confirmed live. - if let Some(dsm) = &original_msg.device_sent_message + if let Some(dsm) = original_msg.device_sent_message.as_option() && let Some(expected) = dsm.phash.as_deref() && !info.bcl_participants.is_empty() && !wacore::messages::MessageUtils::validate_bcl_hash(&info.bcl_participants, expected) @@ -1362,7 +1362,7 @@ impl Client { let mut msg = wacore::messages::unwrap_device_sent(original_msg); // Post-decryption logic (SKDM, sync keys, etc.) - if let Some(skdm) = &msg.sender_key_distribution_message + if let Some(skdm) = msg.sender_key_distribution_message.as_option() && let Some(axolotl_bytes) = &skdm.axolotl_sender_key_distribution_message { self.handle_sender_key_distribution_message( @@ -1378,8 +1378,8 @@ impl Client { // inject keys and forge app-state mutations, so honour it only from // self. WA Web `WAWebKeyManagementHandleKeyShareApi` gates on // `isMeAccountNonLid(from)`; whatsmeow on `info.IsFromMe`. - if let Some(protocol_msg) = &msg.protocol_message - && let Some(keys) = &protocol_msg.app_state_sync_key_share + if let Some(protocol_msg) = msg.protocol_message.as_option() + && let Some(keys) = protocol_msg.app_state_sync_key_share.as_option() { if info.source.is_from_me { self.handle_app_state_sync_key_share(keys).await; @@ -1396,8 +1396,8 @@ impl Client { // companions (WA Web HandleMsgProcess -> setLidMigrationMappings). // Self-only: a peer could otherwise flip the account to LID addressing // and poison the LID-PN cache. - if let Some(protocol_msg) = &msg.protocol_message - && let Some(mapping_sync) = &protocol_msg.lid_migration_mapping_sync_message + if let Some(protocol_msg) = msg.protocol_message.as_option() + && let Some(mapping_sync) = protocol_msg.lid_migration_mapping_sync_message.as_option() { if info.source.is_from_me { self.handle_lid_migration_mapping_sync(mapping_sync).await; @@ -1412,8 +1412,10 @@ impl Client { // PDO responses come from our own account (is_from_me) via device 0 (primary phone) if info.source.is_from_me - && let Some(protocol_msg) = &msg.protocol_message - && let Some(pdo_response) = &protocol_msg.peer_data_operation_request_response_message + && let Some(protocol_msg) = msg.protocol_message.as_option() + && let Some(pdo_response) = protocol_msg + .peer_data_operation_request_response_message + .as_option() { self.handle_pdo_response(pdo_response, info).await; } @@ -1421,7 +1423,7 @@ impl Client { // Note: msg might be modified by take() below let history_sync_taken = msg .protocol_message - .as_mut() + .as_option_mut() .and_then(|pm| pm.history_sync_notification.take()); // history_sync_notification is self-only (our phone drives history sync). diff --git a/src/message/special.rs b/src/message/special.rs index 67f448ea1..b5ca760df 100644 --- a/src/message/special.rs +++ b/src/message/special.rs @@ -1,6 +1,7 @@ //! Special message types: newsletter, app-state key share, sender-key distribution. use super::*; +use buffa::Message as _; impl Client { /// Handles a newsletter plaintext message. @@ -65,15 +66,15 @@ impl Client { /// Extract components from an AppStateSyncKey for storage. fn extract_key_components(key: &wa::message::AppStateSyncKey) -> Option> { - let key_id = key.key_id.as_ref()?.key_id.as_ref()?; - let key_data = key.key_data.as_ref()?; - let fingerprint = key_data.fingerprint.as_ref()?; + let key_id = key.key_id.as_option()?.key_id.as_ref()?; + let key_data = key.key_data.as_option()?; + let fingerprint = key_data.fingerprint.as_option()?; let data = key_data.key_data.as_ref()?; Some(KeyComponents { key_id, data, fingerprint_bytes: fingerprint.encode_to_vec(), - timestamp: key_data.timestamp(), + timestamp: key_data.timestamp.unwrap_or_default(), }) } @@ -132,7 +133,7 @@ impl Client { ) { let skdm = match SenderKeyDistributionMessage::try_from(axolotl_bytes) { Ok(msg) => msg, - Err(e1) => match wa::SenderKeyDistributionMessage::decode(axolotl_bytes) { + Err(e1) => match wa::SenderKeyDistributionMessage::decode_from_slice(axolotl_bytes) { Ok(go_msg) => { let (Some(signing_key), Some(id), Some(iteration), Some(chain_key)) = ( go_msg.signing_key.as_ref(), diff --git a/src/message/tests.rs b/src/message/tests.rs index 87309edd6..e911b5d29 100644 --- a/src/message/tests.rs +++ b/src/message/tests.rs @@ -4188,34 +4188,34 @@ fn test_is_sender_key_distribution_only() { // SKDM only → true assert!(is_sender_key_distribution_only(&mut wa::Message { - sender_key_distribution_message: Some(skdm.clone()), + sender_key_distribution_message: buffa::MessageField::some(skdm.clone()), ..Default::default() })); // SKDM + message_context_info → still true (context_info is metadata) assert!(is_sender_key_distribution_only(&mut wa::Message { - sender_key_distribution_message: Some(skdm.clone()), - message_context_info: Some(Box::default()), + sender_key_distribution_message: buffa::MessageField::some(skdm.clone()), + message_context_info: buffa::MessageField::some(Default::default()), ..Default::default() })); // SKDM + sticker → false (has user content) assert!(!is_sender_key_distribution_only(&mut wa::Message { - sender_key_distribution_message: Some(skdm.clone()), - sticker_message: Some(Box::new(wa::message::StickerMessage::default())), + sender_key_distribution_message: buffa::MessageField::some(skdm.clone()), + sticker_message: buffa::MessageField::some(wa::message::StickerMessage::default()), ..Default::default() })); // SKDM + text → false (has user content) assert!(!is_sender_key_distribution_only(&mut wa::Message { - sender_key_distribution_message: Some(skdm.clone()), + sender_key_distribution_message: buffa::MessageField::some(skdm.clone()), conversation: Some("hello".into()), ..Default::default() })); // protocol_message only (no SKDM) → false assert!(!is_sender_key_distribution_only(&mut wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage::default())), + protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage::default()), ..Default::default() })); } @@ -4225,20 +4225,22 @@ fn skdm_only_detection_restores_carrier_fields() { // The slow path takes the carrier fields out to compare the rest against // default; it must restore them so callers still see the original message. let mut msg = wa::Message { - sender_key_distribution_message: Some(wa::message::SenderKeyDistributionMessage { - group_id: Some("group".into()), - axolotl_sender_key_distribution_message: Some(vec![1, 2, 3]), - }), - fast_ratchet_key_sender_key_distribution_message: Some( + sender_key_distribution_message: buffa::MessageField::some( + wa::message::SenderKeyDistributionMessage { + group_id: Some("group".into()), + axolotl_sender_key_distribution_message: Some(vec![1, 2, 3]), + }, + ), + fast_ratchet_key_sender_key_distribution_message: buffa::MessageField::some( wa::message::SenderKeyDistributionMessage { group_id: Some("group".into()), axolotl_sender_key_distribution_message: Some(vec![4, 5, 6]), }, ), - message_context_info: Some(Box::new(wa::MessageContextInfo { + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(vec![9, 8, 7]), ..Default::default() - })), + }), ..Default::default() }; @@ -4249,21 +4251,21 @@ fn skdm_only_detection_restores_carrier_fields() { // original contents) must fail here. assert_eq!( msg.sender_key_distribution_message - .as_ref() + .as_option() .and_then(|s| s.axolotl_sender_key_distribution_message.as_deref()), Some([1, 2, 3].as_slice()), "sender_key_distribution_message payload must be restored unchanged" ); assert_eq!( msg.fast_ratchet_key_sender_key_distribution_message - .as_ref() + .as_option() .and_then(|s| s.axolotl_sender_key_distribution_message.as_deref()), Some([4, 5, 6].as_slice()), "fast_ratchet carrier payload must be restored unchanged" ); assert_eq!( msg.message_context_info - .as_ref() + .as_option() .and_then(|c| c.message_secret.as_deref()), Some([9, 8, 7].as_slice()), "message_context_info payload must be restored unchanged" @@ -4274,29 +4276,29 @@ fn skdm_only_detection_restores_carrier_fields() { #[test] fn test_unwrap_device_sent_extracts_reaction() { let wrapped = wa::Message { - device_sent_message: Some(Box::new(wa::message::DeviceSentMessage { + device_sent_message: buffa::MessageField::some(wa::message::DeviceSentMessage { destination_jid: Some("5511999999999@s.whatsapp.net".to_string()), - message: Some(Box::new(wa::Message { - reaction_message: Some(Box::new(wa::message::ReactionMessage { + message: buffa::MessageField::some(wa::Message { + reaction_message: buffa::MessageField::some(wa::message::ReactionMessage { text: Some("\u{2764}".to_string()), ..Default::default() - })), + }), ..Default::default() - })), + }), phash: None, - })), + }), ..Default::default() }; let mut unwrapped = unwrap_device_sent(wrapped); assert!( - unwrapped.device_sent_message.is_none(), + unwrapped.device_sent_message.is_unset(), "DSM wrapper should be removed" ); assert_eq!( unwrapped .reaction_message - .as_ref() + .as_option() .and_then(|r| r.text.as_deref()), Some("\u{2764}"), "reaction should be accessible after unwrapping" @@ -4311,17 +4313,17 @@ fn test_unwrap_device_sent_extracts_reaction() { #[test] fn test_unwrap_device_sent_preserves_empty_wrapper() { let wrapped = wa::Message { - device_sent_message: Some(Box::new(wa::message::DeviceSentMessage { + device_sent_message: buffa::MessageField::some(wa::message::DeviceSentMessage { destination_jid: Some("5511999999999@s.whatsapp.net".to_string()), - message: None, + message: Default::default(), phash: None, - })), + }), ..Default::default() }; let result = unwrap_device_sent(wrapped); assert!( - result.device_sent_message.is_some(), + result.device_sent_message.is_set(), "empty DSM wrapper should be preserved" ); } @@ -4344,29 +4346,29 @@ fn test_unwrap_device_sent_passthrough() { fn test_unwrap_device_sent_merges_context_info() { let wrapped = wa::Message { // Outer message_context_info (from the DSM envelope) - message_context_info: Some(Box::new(wa::MessageContextInfo { + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(vec![10, 20, 30]), - limit_sharing_v2: Some(wa::LimitSharing::default()), + limit_sharing_v2: buffa::MessageField::some(wa::LimitSharing::default()), ..Default::default() - })), - device_sent_message: Some(Box::new(wa::message::DeviceSentMessage { + }), + device_sent_message: buffa::MessageField::some(wa::message::DeviceSentMessage { destination_jid: Some("5511999999999@s.whatsapp.net".to_string()), - message: Some(Box::new(wa::Message { + message: buffa::MessageField::some(wa::Message { conversation: Some("hello".to_string()), // Inner has its own message_secret but no limit_sharing_v2 - message_context_info: Some(Box::new(wa::MessageContextInfo { + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(vec![1, 2, 3]), ..Default::default() - })), + }), ..Default::default() - })), + }), phash: None, - })), + }), ..Default::default() }; let result = unwrap_device_sent(wrapped); - let ctx = result.message_context_info.as_ref().unwrap(); + let ctx = result.message_context_info.as_option().unwrap(); assert_eq!( ctx.message_secret, @@ -4374,7 +4376,7 @@ fn test_unwrap_device_sent_merges_context_info() { "inner message_secret should be preferred" ); assert!( - ctx.limit_sharing_v2.is_some(), + ctx.limit_sharing_v2.is_set(), "limit_sharing_v2 should come from outer (always)" ); } @@ -4383,24 +4385,24 @@ fn test_unwrap_device_sent_merges_context_info() { #[test] fn test_unwrap_device_sent_secret_fallback() { let wrapped = wa::Message { - message_context_info: Some(Box::new(wa::MessageContextInfo { + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(vec![10, 20, 30]), ..Default::default() - })), - device_sent_message: Some(Box::new(wa::message::DeviceSentMessage { + }), + device_sent_message: buffa::MessageField::some(wa::message::DeviceSentMessage { destination_jid: Some("5511999999999@s.whatsapp.net".to_string()), - message: Some(Box::new(wa::Message { + message: buffa::MessageField::some(wa::Message { conversation: Some("hello".to_string()), // Inner has no message_context_info at all ..Default::default() - })), + }), phash: None, - })), + }), ..Default::default() }; let result = unwrap_device_sent(wrapped); - let ctx = result.message_context_info.as_ref().unwrap(); + let ctx = result.message_context_info.as_option().unwrap(); assert_eq!( ctx.message_secret, Some(vec![10, 20, 30]), @@ -6363,7 +6365,7 @@ async fn skdm_only_group_session_acknowledged_once_without_message_event() { let group: Jid = "120363408782575443@g.us".parse().expect("group"); let skdm = alice.create_group_skdm(&group).await; let plaintext = MessageUtils::encode_and_pad(&wa::Message { - sender_key_distribution_message: Some(skdm), + sender_key_distribution_message: buffa::MessageField::some(skdm), ..Default::default() }); let session_ct = alice.encrypt(&bob_addr, &plaintext).await; @@ -6491,7 +6493,7 @@ async fn mixed_skdm_and_bad_plaintext_session_is_nacked_not_positive_acked() { let group: Jid = "120363408782575449@g.us".parse().expect("group"); let skdm = alice.create_group_skdm(&group).await; let skdm_plaintext = MessageUtils::encode_and_pad(&wa::Message { - sender_key_distribution_message: Some(skdm), + sender_key_distribution_message: buffa::MessageField::some(skdm), ..Default::default() }); let skdm_ct = alice.encrypt(&bob_addr, &skdm_plaintext).await; @@ -6556,7 +6558,7 @@ async fn bad_session_plaintext_skips_skmsg_sibling_after_nack() { let group: Jid = "120363408782575450@g.us".parse().expect("group"); let skdm = alice.create_group_skdm(&group).await; let skdm_plaintext = MessageUtils::encode_and_pad(&wa::Message { - sender_key_distribution_message: Some(skdm), + sender_key_distribution_message: buffa::MessageField::some(skdm), ..Default::default() }); let skdm_ct = alice.encrypt(&bob_addr, &skdm_plaintext).await; @@ -6623,7 +6625,7 @@ async fn skdm_only_session_with_msmsg_waits_for_bot_payload_response() { let group: Jid = "120363408782575451@g.us".parse().expect("group"); let skdm = alice.create_group_skdm(&group).await; let plaintext = MessageUtils::encode_and_pad(&wa::Message { - sender_key_distribution_message: Some(skdm), + sender_key_distribution_message: buffa::MessageField::some(skdm), ..Default::default() }); let session_ct = alice.encrypt(&bob_addr, &plaintext).await; @@ -6724,7 +6726,7 @@ async fn status_skdm_only_session_uses_one_status_receipt() { let status: Jid = "status@broadcast".parse().expect("status"); let skdm = alice.create_group_skdm(&status).await; let plaintext = MessageUtils::encode_and_pad(&wa::Message { - sender_key_distribution_message: Some(skdm), + sender_key_distribution_message: buffa::MessageField::some(skdm), ..Default::default() }); let session_ct = alice.encrypt(&bob_addr, &plaintext).await; @@ -6829,7 +6831,7 @@ async fn skdm_session_with_skmsg_sibling_acknowledged_once() { let group: Jid = "120363408782575444@g.us".parse().expect("group"); let skdm = alice.create_group_skdm(&group).await; let skdm_plaintext = MessageUtils::encode_and_pad(&wa::Message { - sender_key_distribution_message: Some(skdm), + sender_key_distribution_message: buffa::MessageField::some(skdm), ..Default::default() }); let session_ct = alice.encrypt(&bob_addr, &skdm_plaintext).await; @@ -6890,7 +6892,7 @@ async fn own_group_skdm_only_session_uses_transport_ack_once() { let group: Jid = "120363408782575445@g.us".parse().expect("group"); let skdm = alice.create_group_skdm(&group).await; let plaintext = MessageUtils::encode_and_pad(&wa::Message { - sender_key_distribution_message: Some(skdm), + sender_key_distribution_message: buffa::MessageField::some(skdm), ..Default::default() }); let session_ct = alice.encrypt(&bob_addr, &plaintext).await; @@ -7386,25 +7388,29 @@ async fn app_state_sync_key_share_honored_only_from_self() { let key_id = vec![1u8, 2, 3, 4, 5, 6]; let share = wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage { - app_state_sync_key_share: Some(wa::message::AppStateSyncKeyShare { - keys: vec![wa::message::AppStateSyncKey { - key_id: Some(wa::message::AppStateSyncKeyId { - key_id: Some(key_id.clone()), - }), - key_data: Some(wa::message::AppStateSyncKeyData { - key_data: Some(vec![7u8; 32]), - fingerprint: Some(wa::message::AppStateSyncKeyFingerprint { - raw_id: Some(1), - current_index: Some(0), - device_indexes: vec![0], + protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage { + app_state_sync_key_share: buffa::MessageField::some( + wa::message::AppStateSyncKeyShare { + keys: vec![wa::message::AppStateSyncKey { + key_id: buffa::MessageField::some(wa::message::AppStateSyncKeyId { + key_id: Some(key_id.clone()), }), - timestamp: Some(123), - }), - }], - }), + key_data: buffa::MessageField::some(wa::message::AppStateSyncKeyData { + key_data: Some(vec![7u8; 32]), + fingerprint: buffa::MessageField::some( + wa::message::AppStateSyncKeyFingerprint { + raw_id: Some(1), + current_index: Some(0), + device_indexes: vec![0], + }, + ), + timestamp: Some(123), + }), + }], + }, + ), ..Default::default() - })), + }), ..Default::default() }; let padded = MessageUtils::encode_and_pad(&share); @@ -7447,7 +7453,7 @@ async fn app_state_sync_key_share_honored_only_from_self() { /// LID-PN cache and flip the account to LID wire addressing. #[tokio::test] async fn lid_migration_mapping_sync_honored_only_from_self() { - use prost::Message as _; + use buffa::Message as _; use wacore::messages::MessageUtils; let client = crate::test_utils::create_test_client().await; @@ -7455,8 +7461,8 @@ async fn lid_migration_mapping_sync_honored_only_from_self() { let peer_pn = "5510000123456"; let peer_lid = "222000033334444"; - let payload = wa::LidMigrationMappingSyncPayload { - pn_to_lid_mappings: vec![wa::LidMigrationMapping { + let payload = wa::LIDMigrationMappingSyncPayload { + pn_to_lid_mappings: vec![wa::LIDMigrationMapping { pn: peer_pn.parse().unwrap(), assigned_lid: peer_lid.parse().unwrap(), latest_lid: None, @@ -7464,12 +7470,14 @@ async fn lid_migration_mapping_sync_honored_only_from_self() { chat_db_migration_timestamp: None, }; let sync = wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage { - lid_migration_mapping_sync_message: Some(wa::LidMigrationMappingSyncMessage { - encoded_mapping_payload: Some(payload.encode_to_vec()), - }), + protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage { + lid_migration_mapping_sync_message: buffa::MessageField::some( + wa::LIDMigrationMappingSyncMessage { + encoded_mapping_payload: Some(payload.encode_to_vec()), + }, + ), ..Default::default() - })), + }), ..Default::default() }; let padded = MessageUtils::encode_and_pad(&sync); @@ -7536,14 +7544,14 @@ fn find_message_nack_error(frames: &[bytes::Bytes], id: &str) -> Option { } fn encode_message_secret_message(iv: &[u8], payload: &[u8]) -> Vec { - use prost::Message as _; + use buffa::Message as _; let ms = wa::MessageSecretMessage { version: Some(1), enc_iv: Some(iv.to_vec()), enc_payload: Some(payload.to_vec()), }; - let mut out = Vec::with_capacity(ms.encoded_len()); - ms.encode(&mut out).expect("encode MessageSecretMessage"); + let mut out = Vec::with_capacity(ms.encoded_len() as usize); + ms.encode(&mut out); out } @@ -7572,34 +7580,34 @@ where fn legacy_edit_text(msg: &wa::Message) -> Option<&str> { msg.protocol_message - .as_ref() - .and_then(|pm| pm.edited_message.as_ref()) + .as_option() + .and_then(|pm| pm.edited_message.as_option()) .and_then(|edited| edited.conversation.as_deref()) } fn inner_message_edit(text: &str, next_secret: Option>) -> wa::Message { wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage { - key: Some(wa::MessageKey { + protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage { + key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some("5511777776666@s.whatsapp.net".to_string()), from_me: Some(false), id: Some("PARENT_EDIT".to_string()), participant: None, }), - r#type: Some(wa::message::protocol_message::Type::MessageEdit as i32), - edited_message: Some(Box::new(wa::Message { + r#type: Some(wa::message::protocol_message::Type::MessageEdit), + edited_message: buffa::MessageField::some(wa::Message { conversation: Some(text.to_string()), ..Default::default() - })), + }), timestamp_ms: Some(1_770_000_000_000), ..Default::default() - })), - message_context_info: next_secret.map(|secret| { - Box::new(wa::MessageContextInfo { + }), + message_context_info: next_secret + .map(|secret| wa::MessageContextInfo { message_secret: Some(secret), ..Default::default() }) - }), + .into(), ..Default::default() } } @@ -7626,15 +7634,15 @@ fn encrypted_message_edit( .expect("test edit encryption"); wa::Message { - secret_encrypted_message: Some(Box::new(wa::message::SecretEncryptedMessage { - target_message_key: Some(target_key), + secret_encrypted_message: buffa::MessageField::some(wa::message::SecretEncryptedMessage { + target_message_key: buffa::MessageField::some(target_key), enc_payload: Some(enc_payload), enc_iv: Some(enc_iv.to_vec()), secret_enc_type: Some( - wa::message::secret_encrypted_message::SecretEncType::MessageEdit as i32, + wa::message::secret_encrypted_message::SecretEncType::MessageEdit, ), remote_key_id: None, - })), + }), ..Default::default() } } @@ -7682,7 +7690,7 @@ async fn secret_encrypted_message_edit_dispatches_legacy_edit() { matches!(e, wacore::types::events::Event::Message(msg, info) if info.id == edit_id && legacy_edit_text(msg.as_ref()) == Some("edited") - && msg.secret_encrypted_message.is_none()) + && msg.secret_encrypted_message.is_unset()) }, 500, ) @@ -7743,7 +7751,7 @@ async fn secret_encrypted_peer_edit_resolves_sender_from_envelope() { matches!(e, wacore::types::events::Event::Message(msg, info) if info.id == edit_id && legacy_edit_text(msg.as_ref()) == Some("edited") - && msg.secret_encrypted_message.is_none()) + && msg.secret_encrypted_message.is_unset()) }, 500, ) @@ -7807,7 +7815,7 @@ async fn run_secret_edit_with_window(test_id: &str, parent_ts: i64, edit_offset: matches!(e, wacore::types::events::Event::Message(msg, info) if info.id == edit_id && legacy_edit_text(msg.as_ref()) == Some("edited") - && msg.secret_encrypted_message.is_none()) + && msg.secret_encrypted_message.is_unset()) }, 500, ) @@ -7940,7 +7948,7 @@ async fn secret_encrypted_edit_decrypts_via_resolver_when_store_empty() { matches!(e, wacore::types::events::Event::Message(msg, info) if info.id == edit_id && legacy_edit_text(msg.as_ref()) == Some("edited via resolver") - && msg.secret_encrypted_message.is_none()) + && msg.secret_encrypted_message.is_unset()) }, 500, ) @@ -8269,9 +8277,9 @@ async fn msmsg_decrypts_when_secret_is_stored() { ..Default::default() }; let pt_bytes = { - use prost::Message as _; - let mut v = Vec::with_capacity(plaintext_msg.encoded_len()); - plaintext_msg.encode(&mut v).unwrap(); + use buffa::Message as _; + let mut v = Vec::with_capacity(plaintext_msg.encoded_len() as usize); + plaintext_msg.encode(&mut v); v }; let ctx = BotMessageContext { @@ -8461,9 +8469,9 @@ async fn msmsg_bot_edit_uses_edit_target_id_for_hkdf() { ..Default::default() }; let pt_bytes = { - use prost::Message as _; - let mut v = Vec::with_capacity(plaintext_msg.encoded_len()); - plaintext_msg.encode(&mut v).unwrap(); + use buffa::Message as _; + let mut v = Vec::with_capacity(plaintext_msg.encoded_len() as usize); + plaintext_msg.encode(&mut v); v }; let ctx = BotMessageContext { @@ -8608,9 +8616,9 @@ async fn msmsg_bot_edit_first_keeps_info_id() { ..Default::default() }; let pt_bytes = { - use prost::Message as _; - let mut v = Vec::with_capacity(plaintext_msg.encoded_len()); - plaintext_msg.encode(&mut v).unwrap(); + use buffa::Message as _; + let mut v = Vec::with_capacity(plaintext_msg.encoded_len() as usize); + plaintext_msg.encode(&mut v); v }; let ctx = BotMessageContext { @@ -8682,9 +8690,9 @@ async fn msmsg_falls_back_to_info_id_when_primary_uses_edit_target() { ..Default::default() }; let pt_bytes = { - use prost::Message as _; - let mut v = Vec::with_capacity(plaintext_msg.encoded_len()); - plaintext_msg.encode(&mut v).unwrap(); + use buffa::Message as _; + let mut v = Vec::with_capacity(plaintext_msg.encoded_len() as usize); + plaintext_msg.encode(&mut v); v }; let ctx = BotMessageContext { @@ -8765,9 +8773,9 @@ async fn msmsg_falls_back_to_edit_target_when_primary_uses_info_id() { ..Default::default() }; let pt_bytes = { - use prost::Message as _; - let mut v = Vec::with_capacity(plaintext_msg.encoded_len()); - plaintext_msg.encode(&mut v).unwrap(); + use buffa::Message as _; + let mut v = Vec::with_capacity(plaintext_msg.encoded_len() as usize); + plaintext_msg.encode(&mut v); v }; // Encrypt under edit_target_id even though edit=first → primary @@ -8912,10 +8920,10 @@ async fn maybe_capture_inbound_msg_secret_persists_for_bot_chats() { }); let msg = wa::Message { conversation: Some("hi bot".into()), - message_context_info: Some(Box::new(wa::MessageContextInfo { + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(vec![0xAB; 32]), ..Default::default() - })), + }), ..Default::default() }; client.maybe_capture_inbound_msg_secret(&msg, &info).await; @@ -8952,10 +8960,10 @@ async fn maybe_capture_inbound_msg_secret_persists_for_non_bot_chats() { }); let msg = wa::Message { conversation: Some("hi".into()), - message_context_info: Some(Box::new(wa::MessageContextInfo { + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(vec![0xCD; 32]), ..Default::default() - })), + }), ..Default::default() }; client.maybe_capture_inbound_msg_secret(&msg, &info).await; @@ -9001,18 +9009,18 @@ async fn maybe_capture_inbound_msg_secret_persists_for_group_with_bot_mention() ..Default::default() }); let msg = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("hey @MetaAI tell me a joke".into()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { mentioned_jid: vec!["867051314767696@bot".into()], ..Default::default() - })), + }), ..Default::default() - })), - message_context_info: Some(Box::new(wa::MessageContextInfo { + }), + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(vec![0xEE; 32]), ..Default::default() - })), + }), ..Default::default() }; client.maybe_capture_inbound_msg_secret(&msg, &info).await; @@ -9059,18 +9067,18 @@ async fn maybe_capture_inbound_msg_secret_skips_forwarded() { ..Default::default() }); let msg = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("forwarded".into()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { is_forwarded: Some(true), ..Default::default() - })), + }), ..Default::default() - })), - message_context_info: Some(Box::new(wa::MessageContextInfo { + }), + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(vec![0xFF; 32]), ..Default::default() - })), + }), ..Default::default() }; client.maybe_capture_inbound_msg_secret(&msg, &info).await; @@ -9117,19 +9125,19 @@ async fn maybe_capture_inbound_msg_secret_via_bot_metadata_without_mention() { ..Default::default() }); let msg = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("continue".into()), // No mention at all — just bot_metadata signals the invocation. ..Default::default() - })), - message_context_info: Some(Box::new(wa::MessageContextInfo { + }), + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(vec![0x7B; 32]), - bot_metadata: Some(wa::BotMetadata { + bot_metadata: buffa::MessageField::some(wa::BotMetadata { persona_id: Some("867051314767696".into()), ..Default::default() }), ..Default::default() - })), + }), ..Default::default() }; client.maybe_capture_inbound_msg_secret(&msg, &info).await; @@ -9184,10 +9192,10 @@ async fn bot_only_captures_group_bot_prompt_skips_plain() { }); let plain_msg = wa::Message { conversation: Some("hi".into()), - message_context_info: Some(Box::new(wa::MessageContextInfo { + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(vec![0x01; 32]), ..Default::default() - })), + }), ..Default::default() }; client @@ -9218,18 +9226,18 @@ async fn bot_only_captures_group_bot_prompt_skips_plain() { ..Default::default() }); let bot_msg = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("continue".into()), ..Default::default() - })), - message_context_info: Some(Box::new(wa::MessageContextInfo { + }), + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(vec![0x02; 32]), - bot_metadata: Some(wa::BotMetadata { + bot_metadata: buffa::MessageField::some(wa::BotMetadata { persona_id: Some("867051314767696".into()), ..Default::default() }), ..Default::default() - })), + }), ..Default::default() }; client @@ -9267,18 +9275,18 @@ async fn maybe_capture_inbound_msg_secret_keys_under_other_participant() { ..Default::default() }); let msg = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("@MetaAI question".into()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { mentioned_jid: vec!["867051314767696@bot".into()], ..Default::default() - })), + }), ..Default::default() - })), - message_context_info: Some(Box::new(wa::MessageContextInfo { + }), + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(vec![0x5A; 32]), ..Default::default() - })), + }), ..Default::default() }; client.maybe_capture_inbound_msg_secret(&msg, &info).await; @@ -9443,9 +9451,9 @@ async fn mixed_msmsg_and_unknown_enc_still_decrypts_msmsg() { ..Default::default() }; let pt_bytes = { - use prost::Message as _; - let mut v = Vec::with_capacity(plaintext_msg.encoded_len()); - plaintext_msg.encode(&mut v).unwrap(); + use buffa::Message as _; + let mut v = Vec::with_capacity(plaintext_msg.encoded_len() as usize); + plaintext_msg.encode(&mut v); v }; let ctx = BotMessageContext { @@ -9547,9 +9555,9 @@ async fn msmsg_alternate_lookup_resolves_lid_to_stored_pn() { ..Default::default() }; let pt_bytes = { - use prost::Message as _; - let mut v = Vec::with_capacity(plaintext_msg.encoded_len()); - plaintext_msg.encode(&mut v).unwrap(); + use buffa::Message as _; + let mut v = Vec::with_capacity(plaintext_msg.encoded_len() as usize); + plaintext_msg.encode(&mut v); v }; let ctx = BotMessageContext { @@ -9635,10 +9643,10 @@ async fn fanout_capture_lets_subsequent_msmsg_decrypt() { }); let fanout_msg = wa::Message { conversation: Some("hi bot".into()), - message_context_info: Some(Box::new(wa::MessageContextInfo { + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(secret.to_vec()), ..Default::default() - })), + }), ..Default::default() }; client @@ -9668,9 +9676,9 @@ async fn fanout_capture_lets_subsequent_msmsg_decrypt() { ..Default::default() }; let pt_bytes = { - use prost::Message as _; - let mut v = Vec::with_capacity(plaintext_msg.encoded_len()); - plaintext_msg.encode(&mut v).unwrap(); + use buffa::Message as _; + let mut v = Vec::with_capacity(plaintext_msg.encoded_len() as usize); + plaintext_msg.encode(&mut v); v }; let ctx = BotMessageContext { @@ -9764,9 +9772,9 @@ async fn msmsg_outbound_put_and_inbound_get_match_for_lid_bot() { ..Default::default() }; let pt_bytes = { - use prost::Message as _; - let mut v = Vec::with_capacity(plaintext_msg.encoded_len()); - plaintext_msg.encode(&mut v).unwrap(); + use buffa::Message as _; + let mut v = Vec::with_capacity(plaintext_msg.encoded_len() as usize); + plaintext_msg.encode(&mut v); v }; let ctx = BotMessageContext { @@ -9841,9 +9849,9 @@ async fn msmsg_with_bot_device_suffix_round_trips() { ..Default::default() }; let pt_bytes = { - use prost::Message as _; - let mut v = Vec::with_capacity(plaintext_msg.encoded_len()); - plaintext_msg.encode(&mut v).unwrap(); + use buffa::Message as _; + let mut v = Vec::with_capacity(plaintext_msg.encoded_len() as usize); + plaintext_msg.encode(&mut v); v }; let ctx = BotMessageContext { @@ -9967,11 +9975,11 @@ async fn enc_reaction_inbound_decrypts_to_plaintext_shape() { participant: Some(author.to_string()), }; let msg = wa::Message { - enc_reaction_message: Some(Box::new(wa::message::EncReactionMessage { - target_message_key: Some(target_key.clone()), + enc_reaction_message: buffa::MessageField::some(wa::message::EncReactionMessage { + target_message_key: buffa::MessageField::some(target_key.clone()), enc_payload: Some(payload), enc_iv: Some(iv.to_vec()), - })), + }), ..Default::default() }; let info = Arc::new(MessageInfo { @@ -9990,16 +9998,19 @@ async fn enc_reaction_inbound_decrypts_to_plaintext_shape() { .maybe_decrypt_secret_encrypted_message(&msg, &info) .await .expect("reaction must decrypt"); - let rm = out.reaction_message.expect("plaintext reaction shape"); + let rm = out + .reaction_message + .into_option() + .expect("plaintext reaction shape"); assert_eq!(rm.text.as_deref(), Some("\u{1F525}")); assert_eq!(rm.sender_timestamp_ms, Some(1_700_000_000_000)); assert_eq!( - rm.key.as_ref().and_then(|k| k.id.as_deref()), + rm.key.as_option().and_then(|k| k.id.as_deref()), Some(PARENT_ID), "key must be filled from the envelope target" ); assert_eq!( - rm.key.as_ref().and_then(|k| k.participant.as_deref()), + rm.key.as_option().and_then(|k| k.participant.as_deref()), Some(author.to_string().as_str()) ); } @@ -10040,13 +10051,13 @@ async fn enc_comment_inbound_dispatches_body_with_parent_link() { .expect("persist parent secret"); let body = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("great post".to_string()), ..Default::default() - })), + }), // Present but secret-less: the outer secret must merge in, not be // dropped because a context already exists. - message_context_info: Some(Box::default()), + message_context_info: buffa::MessageField::some(Default::default()), ..Default::default() }; let (payload, iv) = wacore::comment::encrypt_comment_with_secret( @@ -10059,8 +10070,8 @@ async fn enc_comment_inbound_dispatches_body_with_parent_link() { .expect("encrypt"); let msg = wa::Message { - enc_comment_message: Some(Box::new(wa::message::EncCommentMessage { - target_message_key: Some(wa::MessageKey { + enc_comment_message: buffa::MessageField::some(wa::message::EncCommentMessage { + target_message_key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some(group.to_string()), from_me: Some(false), id: Some(PARENT_ID.to_string()), @@ -10068,13 +10079,13 @@ async fn enc_comment_inbound_dispatches_body_with_parent_link() { }), enc_payload: Some(payload), enc_iv: Some(iv.to_vec()), - })), + }), // WA Web ships the comment's own secret on the OUTER envelope (the // comment msgData), not inside the encrypted body. - message_context_info: Some(Box::new(wa::MessageContextInfo { + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(comment_secret.to_vec()), ..Default::default() - })), + }), ..Default::default() }; let info = Arc::new(MessageInfo { @@ -10103,13 +10114,13 @@ async fn enc_comment_inbound_dispatches_body_with_parent_link() { seen = true; assert_eq!( msg.extended_text_message - .as_ref() + .as_option() .and_then(|m| m.text.as_deref()), Some("great post"), "the decrypted body must be dispatched" ); assert!( - msg.enc_comment_message.is_none(), + msg.enc_comment_message.is_unset(), "the envelope must not survive substitution" ); assert_eq!( @@ -10119,7 +10130,7 @@ async fn enc_comment_inbound_dispatches_body_with_parent_link() { ); assert_eq!( msg.message_context_info - .as_ref() + .as_option() .and_then(|m| m.message_secret.as_deref()), Some(comment_secret.as_slice()), "the comment's own secret must survive substitution for app-managed storage" @@ -10178,10 +10189,10 @@ async fn addon_decrypts_right_after_capture_without_flush() { let parent_msg = wa::Message { conversation: Some("hello".to_string()), - message_context_info: Some(Box::new(wa::MessageContextInfo { + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(secret.to_vec()), ..Default::default() - })), + }), ..Default::default() }; let mk_info = |id: &str| { diff --git a/src/pair.rs b/src/pair.rs index e27ceaa4d..1e40c1016 100644 --- a/src/pair.rs +++ b/src/pair.rs @@ -1,8 +1,8 @@ use crate::client::Client; use crate::lid_pn_cache::LearningSource; use crate::types::events::{Event, PairError, PairSuccess}; +use buffa::Message; use log::{debug, error, info, warn}; -use prost::Message; use std::sync::Arc; use std::sync::atomic::Ordering; @@ -232,7 +232,7 @@ async fn handle_pair_success<'a>( match result { Ok((self_signed_identity_bytes, key_index)) => { - let signed_identity_for_event = match wa::AdvSignedDeviceIdentity::decode( + let signed_identity_for_event = match wa::ADVSignedDeviceIdentity::decode_from_slice( self_signed_identity_bytes.as_slice(), ) { Ok(identity) => identity, @@ -277,7 +277,7 @@ async fn handle_pair_success<'a>( // account's state, but a same-account relink whose pair-success // omitted client-props must not lose it either. let props_migrated = PairUtils::extract_pairing_props(success_node) - .is_some_and(|props| props.is_chat_db_lid_migrated()); + .is_some_and(|props| props.is_chat_db_lid_migrated.unwrap_or(false)); let account_changed = device_snapshot .pn .as_ref() diff --git a/src/passkey/flow.rs b/src/passkey/flow.rs index e1788977b..30663e4e6 100644 --- a/src/passkey/flow.rs +++ b/src/passkey/flow.rs @@ -26,6 +26,7 @@ use wacore::shortcake::ShortcakeUtils; use wacore::sync_marker::MaybeSendSync; use wacore_binary::builder::NodeBuilder; use wacore_binary::{Jid, Node, NodeContent, NodeRef, OwnedNodeRef, SERVER_JID, Server}; +use waproto::whatsapp as wa; /// `` routing keys, consumed by the notification dispatcher. pub(crate) const NOTIF_PASSKEY_REQUEST: &str = "passkey_prologue_request"; @@ -52,7 +53,7 @@ const CODE_GROUP_LEN: usize = 4; struct DeviceMaterial { noise_public: [u8; 32], identity_public: [u8; 32], - device_type: i32, + device_type: wa::device_props::PlatformType, } /// The effects the handshake needs from its environment. Abstracted so the full @@ -80,7 +81,7 @@ struct ShortcakeSession { keypair: KeyPair, companion_nonce: [u8; 32], pairing_ref: String, - device_type: i32, + device_type: wa::device_props::PlatformType, new_adv_secret: [u8; 32], skip_handoff_ux: bool, stage: Stage, @@ -363,7 +364,10 @@ impl ShortcakeIo for Client { Ok(DeviceMaterial { noise_public, identity_public, - device_type: snapshot.device_props.platform_type.unwrap_or(0), + device_type: snapshot + .device_props + .platform_type + .unwrap_or(wa::device_props::PlatformType::UNKNOWN), }) } @@ -618,7 +622,7 @@ mod tests { use super::*; use crate::test_utils::{TestEventCollector, create_test_client, node_to_owned_ref}; use crate::types::events::EventHandler; - use prost::Message as _; + use buffa::Message as _; use std::sync::Mutex; use std::time::Duration; use wacore::libsignal::protocol::PublicKey; @@ -726,7 +730,7 @@ mod tests { let device = DeviceMaterial { noise_public: [0x11; 32], identity_public: [0x12; 32], - device_type: 1, + device_type: wa::device_props::PlatformType::CHROME, }; let io = MockIo { device: device.clone(), @@ -798,8 +802,8 @@ mod tests { // The primary decrypts the pairing request and reads the SAME secret that // was committed — proving the deferred rotation delivers what it persists. let prologue_payload = child_bytes(&prologue, TAG_PROLOGUE_PAYLOAD); - let companion_eph_pub = wa::CompanionEphemeralIdentity::decode( - wa::ProloguePayload::decode(prologue_payload.as_slice()) + let companion_eph_pub = wa::CompanionEphemeralIdentity::decode_from_slice( + wa::ProloguePayload::decode_from_slice(prologue_payload.as_slice()) .unwrap() .companion_ephemeral_identity .unwrap() @@ -812,13 +816,17 @@ mod tests { .private_key .calculate_agreement(&PublicKey::from_djb_public_key_bytes(&companion_eph_pub).unwrap()) .unwrap(); - let key = ShortcakeUtils::derive_encryption_key_from_shared_secret(&shared, 1, "REF-XYZ") - .unwrap(); + let key = ShortcakeUtils::derive_encryption_key_from_shared_secret( + &shared, + wa::device_props::PlatformType::CHROME, + "REF-XYZ", + ) + .unwrap(); let wrapped = match io.sent_node(TAG_ENCRYPTED_PAIRING_REQUEST).content { Some(NodeContent::Bytes(bytes)) => bytes, _ => panic!("encrypted_pairing_request must carry bytes"), }; - let epr = wa::EncryptedPairingRequest::decode(wrapped.as_slice()).unwrap(); + let epr = wa::EncryptedPairingRequest::decode_from_slice(wrapped.as_slice()).unwrap(); let iv: [u8; 12] = epr.iv.unwrap().as_slice().try_into().unwrap(); let mut plaintext = Vec::new(); wacore::libsignal::crypto::aes_256_gcm_decrypt( @@ -829,7 +837,7 @@ mod tests { &mut plaintext, ) .unwrap(); - let pr = wa::PairingRequest::decode(plaintext.as_slice()).unwrap(); + let pr = wa::PairingRequest::decode_from_slice(plaintext.as_slice()).unwrap(); assert_eq!(pr.adv_secret.as_deref(), Some(&committed[..])); assert_eq!( pr.companion_public_key.as_deref(), @@ -845,7 +853,7 @@ mod tests { keypair: KeyPair::generate(&mut rand::make_rng::()), companion_nonce: [0; 32], pairing_ref: "r".into(), - device_type: 1, + device_type: wa::device_props::PlatformType::CHROME, new_adv_secret: [1; 32], skip_handoff_ux: false, stage: Stage::AwaitingPrimaryIdentity, @@ -972,7 +980,7 @@ mod tests { nonce: Some(vec![0xCD; 32]), }; let child = NodeBuilder::new(TAG_PRIMARY_EPHEMERAL_IDENTITY) - .bytes(prost::Message::encode_to_vec(&primary)) + .bytes(buffa::Message::encode_to_vec(&primary)) .build(); client .process_node(server_notification(NOTIF_PASSKEY_CONTINUATION, Some(child))) diff --git a/src/pdo.rs b/src/pdo.rs index 58bec1760..40021e39e 100644 --- a/src/pdo.rs +++ b/src/pdo.rs @@ -150,11 +150,11 @@ impl Client { // Build the PDO request message let pdo_request = wa::message::PeerDataOperationRequestMessage { peer_data_operation_request_type: Some( - wa::message::PeerDataOperationRequestType::PlaceholderMessageResend as i32, + wa::message::PeerDataOperationRequestType::PLACEHOLDER_MESSAGE_RESEND, ), placeholder_message_resend_request: vec![ wa::message::peer_data_operation_request_message::PlaceholderMessageResendRequest { - message_key: Some(message_key), + message_key: buffa::MessageField::some(message_key), }, ], ..Default::default() @@ -162,15 +162,13 @@ impl Client { // Wrap it in a protocol message let protocol_message = wa::message::ProtocolMessage { - r#type: Some( - wa::message::protocol_message::Type::PeerDataOperationRequestMessage as i32, - ), - peer_data_operation_request_message: Some(pdo_request), + r#type: Some(wa::message::protocol_message::Type::PEER_DATA_OPERATION_REQUEST_MESSAGE), + peer_data_operation_request_message: buffa::MessageField::some(pdo_request), ..Default::default() }; let msg = wa::Message { - protocol_message: Some(Box::new(protocol_message)), + protocol_message: buffa::MessageField::some(protocol_message), ..Default::default() }; @@ -222,9 +220,9 @@ impl Client { let pdo_request = wa::message::PeerDataOperationRequestMessage { peer_data_operation_request_type: Some( - wa::message::PeerDataOperationRequestType::HistorySyncOnDemand as i32, + wa::message::PeerDataOperationRequestType::HISTORY_SYNC_ON_DEMAND, ), - history_sync_on_demand_request: Some( + history_sync_on_demand_request: buffa::MessageField::some( wa::message::peer_data_operation_request_message::HistorySyncOnDemandRequest { chat_jid: Some(chat_jid.to_string()), oldest_msg_id: Some(oldest_msg_id.to_string()), @@ -238,15 +236,13 @@ impl Client { }; let protocol_message = wa::message::ProtocolMessage { - r#type: Some( - wa::message::protocol_message::Type::PeerDataOperationRequestMessage as i32, - ), - peer_data_operation_request_message: Some(pdo_request), + r#type: Some(wa::message::protocol_message::Type::PEER_DATA_OPERATION_REQUEST_MESSAGE), + peer_data_operation_request_message: buffa::MessageField::some(pdo_request), ..Default::default() }; let msg = wa::Message { - protocol_message: Some(Box::new(protocol_message)), + protocol_message: buffa::MessageField::some(protocol_message), ..Default::default() }; @@ -317,7 +313,9 @@ impl Client { ); for result in &response.peer_data_operation_result { - if let Some(placeholder_response) = &result.placeholder_message_resend_response { + if let Some(placeholder_response) = + result.placeholder_message_resend_response.as_option() + { self.handle_placeholder_resend_response(placeholder_response, request_id) .await; } @@ -334,16 +332,23 @@ impl Client { return; }; - let web_msg_info = - match waproto::codec::web_message_info_decode(web_message_info_bytes.as_slice()) { - Ok(info) => info, - Err(e) => { - warn!("Failed to decode WebMessageInfo from PDO response: {:?}", e); - return; - } - }; + // Owned decode (not a view): WebMessageInfo carries a nested `message` + // (a full Message), so an eager view would pull the entire MessageView + // tree into the binary and parse the message once into a view only to + // copy it again into the owned form. Owned decode reads it in one pass. + let mut web_msg_info = match waproto::codec::web_message_info_decode(web_message_info_bytes) + { + Ok(info) => info, + Err(e) => { + warn!("Failed to decode WebMessageInfo from PDO response: {:?}", e); + return; + } + }; - let key = &web_msg_info.key; + let Some(key) = web_msg_info.key.as_option() else { + warn!("PDO response WebMessageInfo missing key"); + return; + }; let remote_jid_str = key.remote_jid.as_deref().unwrap_or(""); let msg_id = key.id.as_deref().unwrap_or(""); @@ -385,7 +390,7 @@ impl Client { } }; - let Some(message) = web_msg_info.message else { + let Some(message) = web_msg_info.message.take() else { // Expected when the phone could not decrypt the message either; // WA Web only counts this outcome in telemetry, with no warning. info!("PDO response WebMessageInfo missing message content"); @@ -426,16 +431,36 @@ impl Client { &self, web_msg: &wa::WebMessageInfo, ) -> Result { - let key = &web_msg.key; + let Some(key) = web_msg.key.as_option() else { + anyhow::bail!("WebMessageInfo missing key"); + }; - let remote_jid: Jid = key - .remote_jid - .as_ref() + self.message_info_from_web_message_parts( + key.remote_jid.as_deref(), + key.from_me, + key.id.as_deref(), + key.participant.as_deref(), + web_msg.message_timestamp, + web_msg.push_name.as_deref(), + ) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn message_info_from_web_message_parts( + &self, + remote_jid: Option<&str>, + from_me: Option, + id: Option<&str>, + participant: Option<&str>, + message_timestamp: Option, + push_name: Option<&str>, + ) -> Result { + let remote_jid: Jid = remote_jid .ok_or_else(|| anyhow::anyhow!("MessageKey missing remoteJid"))? .parse()?; - let is_group = remote_jid.is_group(); - let is_from_me = key.from_me.unwrap_or(false); + let is_from_me = from_me.unwrap_or(false); // `key.participant` is the real author for any chat where the sender // differs from the remote_jid — groups AND broadcasts (including @@ -443,7 +468,7 @@ impl Client { // `status@broadcast` as the sender and erase the author. Matches the // response-handler construction in WAWebNonMessageDataRequestHandlerPlaceholderResend // which maps participant to `author` for both broadcast branches. - let sender = if let Some(p) = key.participant.as_ref() { + let sender = if let Some(p) = participant { p.parse()? } else if is_from_me { self.persistence_manager @@ -455,13 +480,12 @@ impl Client { remote_jid.clone() }; - let timestamp = web_msg - .message_timestamp + let timestamp = message_timestamp .map(|ts| wacore::time::from_secs_or_now(ts as i64)) .unwrap_or_else(wacore::time::now_utc); Ok(MessageInfo { - id: key.id.clone().unwrap_or_default(), + id: id.unwrap_or_default().to_owned(), server_id: 0, r#type: String::new(), source: MessageSource { @@ -476,7 +500,7 @@ impl Client { recipient: None, }, timestamp, - push_name: web_msg.push_name.clone().unwrap_or_default(), + push_name: push_name.unwrap_or_default().to_owned(), category: MessageCategory::default(), multicast: false, media_type: String::new(), @@ -630,12 +654,12 @@ mod tests { ) -> waproto::whatsapp::WebMessageInfo { use waproto::whatsapp as wa; wa::WebMessageInfo { - key: wa::MessageKey { + key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some(remote_jid.into()), from_me: Some(from_me), id: Some(id.into()), participant: participant.map(|p| p.into()), - }, + }), ..Default::default() } } @@ -675,6 +699,35 @@ mod tests { assert_eq!(info.source.sender.to_string(), peer); } + #[tokio::test] + async fn test_reconstruct_from_web_message_info_view() { + use buffa::Message as _; + use waproto::whatsapp as wa; + + let client = setup_reconstruct_client().await; + let author_jid = "203040904720543@lid"; + let mut web_msg = make_web_msg( + "status@broadcast", + false, + "STATUS_PDO_VIEW_1", + Some(author_jid), + ); + web_msg.push_name = Some("Recovered Sender".to_string()); + web_msg.message_timestamp = Some(1_700_000_000); + let encoded = web_msg.encode_to_vec(); + let decoded = wa::WebMessageInfo::decode_from_slice(&encoded).expect("should decode"); + + let info = client + .message_info_from_web_message_info(&decoded) + .await + .unwrap(); + + assert_eq!(info.id, "STATUS_PDO_VIEW_1"); + assert_eq!(info.source.chat.to_string(), "status@broadcast"); + assert_eq!(info.source.sender.to_string(), author_jid); + assert_eq!(info.push_name, "Recovered Sender"); + } + /// LID-migrated 1-on-1 responses carry `remote_jid` in LID form and no /// `participant` (WA Web's request side strips it when building the new /// MsgKey, and `msgKeyToProtobuf` then omits it). Reconstruction must @@ -821,7 +874,7 @@ mod tests { /// re-asking on the next redelivery cannot produce content either. #[tokio::test] async fn pdo_missing_content_response_clears_pending_but_keeps_memo() { - use prost::Message as _; + use buffa::Message as _; use wacore::types::message::ChatMessageId; let client = setup_reconstruct_client().await; @@ -842,12 +895,12 @@ mod tests { .await; let web_msg = waproto::whatsapp::WebMessageInfo { - key: waproto::whatsapp::MessageKey { + key: buffa::MessageField::some(waproto::whatsapp::MessageKey { remote_jid: Some(chat.to_owned()), from_me: Some(false), id: Some(msg_id.to_owned()), participant: None, - }, + }), ..Default::default() }; let response = waproto::whatsapp::message::peer_data_operation_request_response_message::peer_data_operation_result::PlaceholderMessageResendResponse { diff --git a/src/prekeys.rs b/src/prekeys.rs index 152982118..695efd092 100644 --- a/src/prekeys.rs +++ b/src/prekeys.rs @@ -353,8 +353,9 @@ impl Client { }) .await; } - use prost::Message; - let structure = waproto::whatsapp::PreKeyRecordStructure::decode(&record[..])?; + use buffa::Message; + let structure = + waproto::whatsapp::PreKeyRecordStructure::decode_from_slice(&record)?; let record = wacore::libsignal::store::record_helpers::prekey_structure_to_record( structure, )?; @@ -380,7 +381,7 @@ impl Client { }; let key_pair = KeyPair::generate(&mut rand::make_rng::()); let record = new_pre_key_record(id, &key_pair); - use prost::Message; + use buffa::Message; backend .store_prekey(id, &record.encode_to_vec(), false) .await?; @@ -468,7 +469,7 @@ impl Client { // the async executor responsive. Records are encoded into one contiguous // buffer with zero-copy Bytes slices instead of an alloc per record. let (encoded_batch, generated) = wacore::runtime::blocking(&*self.runtime, move || { - use prost::Message; + use buffa::Message; // Seed one CSPRNG and advance it per key, rather than reseeding from // entropy on every iteration. @@ -488,9 +489,7 @@ impl Client { let pre_key_id = gen_start + i as u32; let key_pair = KeyPair::generate(&mut rng); let start = buf.len(); - new_pre_key_record(pre_key_id, &key_pair) - .encode(&mut buf) - .expect("prost encode into pre-sized Vec"); + new_pre_key_record(pre_key_id, &key_pair).encode(&mut buf); offsets.push((pre_key_id, start..buf.len())); pubkeys.push((pre_key_id, key_pair.public_key)); } @@ -572,16 +571,17 @@ impl Client { // `PreKeyRecordStructure::decode` the consume path runs, so a record // accepted here is one this device can later decrypt with. Fresh keys skip // decode entirely; their public keys never left memory. - use prost::Message; + use buffa::Message; for (id, record) in &leftover_rows { - let public_key = waproto::whatsapp::PreKeyRecordStructure::decode(&record[..]) - .map_err(anyhow::Error::from) - .and_then(|s| { - let raw = s - .public_key - .ok_or_else(|| anyhow::anyhow!("record missing public key"))?; - PublicKey::from_djb_public_key_bytes(&raw).map_err(anyhow::Error::from) - }); + let public_key = + waproto::whatsapp::PreKeyRecordStructure::decode_from_slice(&record[..]) + .map_err(anyhow::Error::from) + .and_then(|s| { + let raw = s + .public_key + .ok_or_else(|| anyhow::anyhow!("record missing public key"))?; + PublicKey::from_djb_public_key_bytes(&raw).map_err(anyhow::Error::from) + }); match public_key { Ok(public_key) => pairs.push((*id, public_key)), Err(e) => log::warn!("skipping undecodable prekey record {id}: {e:?}"), @@ -1108,7 +1108,7 @@ mod window_tests { /// heal advances FIRST to the next stored id and reuses it. #[tokio::test] async fn consumed_head_advances_to_next_live_window_key() { - use prost::Message; + use buffa::Message; use wacore::libsignal::protocol::KeyPair; use wacore::libsignal::store::record_helpers::new_pre_key_record; use wacore::store::commands::DeviceCommand; @@ -1221,9 +1221,10 @@ mod window_tests { .expect("load"); assert_eq!(window.len(), 5, "window = retry key + top-up"); - use prost::Message; - let structure = waproto::whatsapp::PreKeyRecordStructure::decode(&after[0].1[..]) - .expect("decode structure"); + use buffa::Message; + let structure = + waproto::whatsapp::PreKeyRecordStructure::decode_from_slice(&after[0].1[..]) + .expect("decode structure"); let reloaded = PublicKey::from_djb_public_key_bytes( structure.public_key.as_deref().expect("public key"), ) diff --git a/src/retry.rs b/src/retry.rs index 42eab1f7c..0226025b0 100644 --- a/src/retry.rs +++ b/src/retry.rs @@ -1,8 +1,8 @@ use crate::client::Client; use crate::message::RetryReason; use crate::types::events::Receipt; +use buffa::Message; use log::{debug, info, warn}; -use prost::Message; use wacore::types::message::MessageCategory; use scopeguard; @@ -1857,14 +1857,14 @@ mod tests { remote_identity_public: None, root_key: None, previous_counter: Some(0), - sender_chain: None, + sender_chain: buffa::MessageField::default(), receiver_chains: vec![], - pending_pre_key: None, + pending_pre_key: buffa::MessageField::default(), remote_registration_id: Some(remote_regid), local_registration_id: Some(0), alice_base_key: Some(base_key), needs_refresh: None, - pending_key_exchange: None, + pending_key_exchange: buffa::MessageField::default(), }); SessionRecord::new(state) .serialize() @@ -3083,10 +3083,10 @@ mod tests { .await; let msg = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("status text".to_string()), ..Default::default() - })), + }), ..Default::default() }; @@ -3112,7 +3112,7 @@ mod tests { .unwrap() .0 .extended_text_message - .as_ref() + .as_option() .unwrap() .text .as_deref(), diff --git a/src/send/actions.rs b/src/send/actions.rs index d6e4bdae2..e3248c885 100644 --- a/src/send/actions.rs +++ b/src/send/actions.rs @@ -148,15 +148,15 @@ impl Client { duration_secs: u32, ) -> Result<(), SendError> { let message = wa::Message { - pin_in_chat_message: Some(Box::new(wa::message::PinInChatMessage { - key: Some(key), - r#type: Some(pin_type as i32), + pin_in_chat_message: buffa::MessageField::some(wa::message::PinInChatMessage { + key: buffa::MessageField::some(key), + r#type: Some(pin_type), sender_timestamp_ms: Some(wacore::time::now_millis()), - })), - message_context_info: Some(Box::new(wa::MessageContextInfo { + }), + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_add_on_duration_in_secs: Some(duration_secs), ..Default::default() - })), + }), ..Default::default() }; diff --git a/src/send/mod.rs b/src/send/mod.rs index 845caedeb..27e3f43d5 100644 --- a/src/send/mod.rs +++ b/src/send/mod.rs @@ -214,34 +214,34 @@ pub(crate) fn infer_stanza_metadata(msg: &wa::Message) -> (Option let mut meta = NodeBuilder::new("meta"); let mut has_attr = false; - if msg.poll_creation_message.is_some() - || msg.poll_creation_message_v2.is_some() - || msg.poll_creation_message_v3.is_some() + if msg.poll_creation_message.is_set() + || msg.poll_creation_message_v2.is_set() + || msg.poll_creation_message_v3.is_set() { meta = meta.attr("polltype", "creation"); has_attr = true; - } else if let Some(ref poll_update) = msg.poll_update_message - && poll_update.vote.is_some() + } else if let Some(poll_update) = msg.poll_update_message.as_option() + && poll_update.vote.is_set() { meta = meta.attr("polltype", "vote"); has_attr = true; // TODO: polltype="result_snapshot" for poll_result_snapshot_message (gated behind AB flag) - } else if msg.event_message.is_some() { + } else if msg.event_message.is_set() { meta = meta.attr("event_type", "creation"); has_attr = true; - } else if msg.enc_event_response_message.is_some() { + } else if msg.enc_event_response_message.is_set() { meta = meta.attr("event_type", "response"); has_attr = true; - } else if let Some(ref sec) = msg.secret_encrypted_message + } else if let Some(sec) = msg.secret_encrypted_message.as_option() && sec.secret_enc_type - == Some(wa::message::secret_encrypted_message::SecretEncType::EventEdit as i32) + == Some(wa::message::secret_encrypted_message::SecretEncType::EventEdit) { meta = meta.attr("event_type", "edit"); has_attr = true; } else if let Some(ml) = msg .protocol_message - .as_ref() - .and_then(|pm| pm.member_label.as_ref()) + .as_option() + .and_then(|pm| pm.member_label.as_option()) { // genMetaNode (MsgMetaNode `d`/`p`): a member_label protocol message carries // appdata="member_tag" and tag_reason="user_delete" when the label is cleared @@ -369,13 +369,13 @@ fn build_nested_biz(privacy_mode_ts: &str, flow_name: &str) -> Node { fn extract_interactive_message(msg: &wa::Message) -> Option<&wa::message::InteractiveMessage> { // Only checks documentWithCaptionMessage wrapper (for media headers) and direct field. // Does not use unwrap_message() since we need the InteractiveMessage specifically. - if let Some(ref doc) = msg.document_with_caption_message - && let Some(ref inner) = doc.message - && let Some(ref im) = inner.interactive_message + if let Some(doc) = msg.document_with_caption_message.as_option() + && let Some(inner) = doc.message.as_option() + && let Some(im) = inner.interactive_message.as_option() { return Some(im); } - msg.interactive_message.as_deref() + msg.interactive_message.as_option() } /// Assemble the `extra_stanza_nodes` vector for a non-newsletter send. @@ -413,16 +413,16 @@ fn build_revoke_message( participant: Option, ) -> wa::Message { wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage { - key: Some(wa::MessageKey { + protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage { + key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some(remote_jid.to_string()), from_me: Some(from_me), id: Some(message_id), participant, }), - r#type: Some(wa::message::protocol_message::Type::Revoke as i32), + r#type: Some(wa::message::protocol_message::Type::Revoke), ..Default::default() - })), + }), ..Default::default() } } @@ -482,18 +482,18 @@ pub(crate) fn build_edit_message( timestamp_ms: i64, ) -> wa::Message { wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage { - key: Some(wa::MessageKey { + protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage { + key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some(remote_jid.to_string()), from_me: Some(true), id: Some(message_id), participant, }), - r#type: Some(wa::message::protocol_message::Type::MessageEdit as i32), - edited_message: Some(Box::new(new_content)), + r#type: Some(wa::message::protocol_message::Type::MessageEdit), + edited_message: buffa::MessageField::some(new_content), timestamp_ms: Some(timestamp_ms), ..Default::default() - })), + }), ..Default::default() } } @@ -1283,8 +1283,8 @@ impl Client { let (to, is_status_addon) = if to.is_status_broadcast() { let author = message .reaction_message - .as_ref() - .and_then(|rm| rm.key.as_ref()) + .as_option() + .and_then(|rm| rm.key.as_option()) .and_then(|k| k.participant.as_ref()) .and_then(|p| p.parse::().ok()) .filter(|jid| jid.is_pn() || jid.is_lid()) @@ -2135,8 +2135,8 @@ mod tests { .send_message( to, wa::Message { - reaction_message: Some(Box::new(wa::message::ReactionMessage { - key: Some(wa::MessageKey { + reaction_message: buffa::MessageField::some(wa::message::ReactionMessage { + key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some("status@broadcast".into()), from_me: Some(false), id: Some("ORIGID".into()), @@ -2145,7 +2145,7 @@ mod tests { text: Some("❤️".into()), sender_timestamp_ms: Some(1), ..Default::default() - })), + }), ..Default::default() }, ) @@ -2165,8 +2165,8 @@ mod tests { .send_message( to, wa::Message { - reaction_message: Some(Box::new(wa::message::ReactionMessage { - key: Some(wa::MessageKey { + reaction_message: buffa::MessageField::some(wa::message::ReactionMessage { + key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some("status@broadcast".into()), from_me: Some(false), id: Some("ORIGID".into()), @@ -2175,7 +2175,7 @@ mod tests { text: Some("❤️".into()), sender_timestamp_ms: Some(1), ..Default::default() - })), + }), ..Default::default() }, ) @@ -2243,8 +2243,8 @@ mod tests { let revoke_message = build_revoke_message(&to, from_me, message_id.clone(), participant); - let proto_msg = revoke_message.protocol_message.unwrap(); - let key = proto_msg.key.unwrap(); + let proto_msg = revoke_message.protocol_message.into_option().unwrap(); + let key = proto_msg.key.into_option().unwrap(); assert_eq!(key.from_me, Some(true)); assert_eq!(key.participant, None); assert_eq!(key.id, Some(message_id)); @@ -2284,8 +2284,8 @@ mod tests { let revoke_message = build_revoke_message(&to, from_me, message_id.clone(), participant.clone()); - let proto_msg = revoke_message.protocol_message.unwrap(); - let key = proto_msg.key.unwrap(); + let proto_msg = revoke_message.protocol_message.into_option().unwrap(); + let key = proto_msg.key.into_option().unwrap(); assert_eq!(key.from_me, Some(false)); // Participant should be the original sender with device number stripped assert_eq!(key.participant, Some("236395184570386@lid".to_string())); @@ -2865,7 +2865,7 @@ mod tests { #[test] fn pin_returns_edit_attribute() { let msg = wa::Message { - pin_in_chat_message: Some(Box::default()), + pin_in_chat_message: buffa::MessageField::some(Default::default()), ..Default::default() }; let (edit, node) = infer_stanza_metadata(&msg); @@ -2876,7 +2876,7 @@ mod tests { #[test] fn poll_creation_v3_returns_meta_node() { let msg = wa::Message { - poll_creation_message_v3: Some(Box::default()), + poll_creation_message_v3: buffa::MessageField::some(Default::default()), ..Default::default() }; let (edit, node) = infer_stanza_metadata(&msg); @@ -2893,7 +2893,7 @@ mod tests { #[test] fn event_returns_meta_node() { let msg = wa::Message { - event_message: Some(Box::default()), + event_message: buffa::MessageField::some(Default::default()), ..Default::default() }; let (edit, node) = infer_stanza_metadata(&msg); @@ -2950,7 +2950,7 @@ mod tests { #[test] fn poll_creation_v1_returns_meta_node() { let msg = wa::Message { - poll_creation_message: Some(Box::default()), + poll_creation_message: buffa::MessageField::some(Default::default()), ..Default::default() }; let (edit, node) = infer_stanza_metadata(&msg); @@ -2967,7 +2967,7 @@ mod tests { #[test] fn poll_creation_v2_returns_meta_node() { let msg = wa::Message { - poll_creation_message_v2: Some(Box::default()), + poll_creation_message_v2: buffa::MessageField::some(Default::default()), ..Default::default() }; let (edit, node) = infer_stanza_metadata(&msg); @@ -2984,10 +2984,10 @@ mod tests { #[test] fn poll_vote_returns_meta_node() { let msg = wa::Message { - poll_update_message: Some(Box::new(wa::message::PollUpdateMessage { - vote: Some(wa::message::PollEncValue::default()), + poll_update_message: buffa::MessageField::some(wa::message::PollUpdateMessage { + vote: buffa::MessageField::some(wa::message::PollEncValue::default()), ..Default::default() - })), + }), ..Default::default() }; let (edit, node) = infer_stanza_metadata(&msg); @@ -3001,10 +3001,10 @@ mod tests { #[test] fn view_once_image_emits_view_once_meta() { let msg = wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage { + image_message: buffa::MessageField::some(wa::message::ImageMessage { view_once: Some(true), ..Default::default() - })), + }), ..Default::default() }; let (_, node) = infer_stanza_metadata(&msg); @@ -3019,7 +3019,7 @@ mod tests { #[test] fn plain_image_emits_no_meta() { let msg = wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage::default())), + image_message: buffa::MessageField::some(wa::message::ImageMessage::default()), ..Default::default() }; assert!(infer_stanza_metadata(&msg).1.is_none()); @@ -3028,7 +3028,7 @@ mod tests { #[test] fn event_response_returns_meta_node() { let msg = wa::Message { - enc_event_response_message: Some(Box::default()), + enc_event_response_message: buffa::MessageField::some(Default::default()), ..Default::default() }; let (edit, node) = infer_stanza_metadata(&msg); @@ -3045,10 +3045,9 @@ mod tests { #[test] fn poll_update_without_vote_returns_none() { let msg = wa::Message { - poll_update_message: Some(Box::new(wa::message::PollUpdateMessage { - vote: None, + poll_update_message: buffa::MessageField::some(wa::message::PollUpdateMessage { ..Default::default() - })), + }), ..Default::default() }; let (edit, node) = infer_stanza_metadata(&msg); @@ -3059,10 +3058,10 @@ mod tests { #[test] fn revoked_reaction_returns_sender_revoke() { let msg = wa::Message { - reaction_message: Some(Box::new(wa::message::ReactionMessage { + reaction_message: buffa::MessageField::some(wa::message::ReactionMessage { text: Some(String::new()), ..Default::default() - })), + }), ..Default::default() }; let (edit, _) = infer_stanza_metadata(&msg); @@ -3072,14 +3071,14 @@ mod tests { #[test] fn keep_in_chat_undo_returns_sender_revoke() { let msg = wa::Message { - keep_in_chat_message: Some(Box::new(wa::message::KeepInChatMessage { - key: Some(wa::MessageKey { + keep_in_chat_message: buffa::MessageField::some(wa::message::KeepInChatMessage { + key: buffa::MessageField::some(wa::MessageKey { from_me: Some(true), ..Default::default() }), - keep_type: Some(wa::KeepType::UndoKeepForAll as i32), + keep_type: Some(wa::KeepType::UndoKeepForAll), ..Default::default() - })), + }), ..Default::default() }; let (edit, _) = infer_stanza_metadata(&msg); @@ -3089,12 +3088,14 @@ mod tests { #[test] fn secret_encrypted_message_edit_returns_message_edit() { let msg = wa::Message { - secret_encrypted_message: Some(Box::new(wa::message::SecretEncryptedMessage { - secret_enc_type: Some( - wa::message::secret_encrypted_message::SecretEncType::MessageEdit as i32, - ), - ..Default::default() - })), + secret_encrypted_message: buffa::MessageField::some( + wa::message::SecretEncryptedMessage { + secret_enc_type: Some( + wa::message::secret_encrypted_message::SecretEncType::MessageEdit, + ), + ..Default::default() + }, + ), ..Default::default() }; let (edit, _) = infer_stanza_metadata(&msg); @@ -3106,12 +3107,14 @@ mod tests { // EVENT_EDIT is the one case where the edit attribute AND the // meta node both fire: `event_type=edit` meta + `edit="1"` attr. let msg = wa::Message { - secret_encrypted_message: Some(Box::new(wa::message::SecretEncryptedMessage { - secret_enc_type: Some( - wa::message::secret_encrypted_message::SecretEncType::EventEdit as i32, - ), - ..Default::default() - })), + secret_encrypted_message: buffa::MessageField::some( + wa::message::SecretEncryptedMessage { + secret_enc_type: Some( + wa::message::secret_encrypted_message::SecretEncType::EventEdit, + ), + ..Default::default() + }, + ), ..Default::default() }; let (edit, node) = infer_stanza_metadata(&msg); @@ -3126,9 +3129,9 @@ mod tests { #[test] fn top_level_edited_message_returns_message_edit() { let msg = wa::Message { - edited_message: Some(Box::new(wa::message::FutureProofMessage { - message: Some(Box::new(wa::Message::default())), - })), + edited_message: buffa::MessageField::some(wa::message::FutureProofMessage { + message: buffa::MessageField::some(wa::Message::default()), + }), ..Default::default() }; let (edit, _) = infer_stanza_metadata(&msg); @@ -3154,25 +3157,25 @@ mod tests { // Canonical WA Web shape: top-level protocolMessage(type=MESSAGE_EDIT), // not the Message.editedMessage FutureProofMessage history wrapper. assert!( - msg.edited_message.is_none(), + msg.edited_message.is_unset(), "edit must not use the FutureProofMessage wrapper" ); let pm = msg .protocol_message - .as_deref() + .as_option() .expect("top-level protocol_message"); assert_eq!( pm.r#type, - Some(wa::message::protocol_message::Type::MessageEdit as i32) + Some(wa::message::protocol_message::Type::MessageEdit) ); assert_eq!( - pm.key.as_ref().and_then(|k| k.id.as_deref()), + pm.key.as_option().and_then(|k| k.id.as_deref()), Some("ORIG_ID") ); - assert_eq!(pm.key.as_ref().and_then(|k| k.from_me), Some(true)); + assert_eq!(pm.key.as_option().and_then(|k| k.from_me), Some(true)); assert_eq!( pm.edited_message - .as_ref() + .as_option() .and_then(|m| m.conversation.as_deref()), Some("edited") ); @@ -3198,9 +3201,9 @@ mod tests { fn msg_with_native_flow_button(button_name: &str) -> wa::Message { wa::Message { - interactive_message: Some(Box::new(wa::message::InteractiveMessage { + interactive_message: buffa::MessageField::some(wa::message::InteractiveMessage { interactive_message: Some( - interactive_message::InteractiveMessage::NativeFlowMessage( + interactive_message::InteractiveMessage::NativeFlowMessage(Box::new( NativeFlowMessage { buttons: vec![NativeFlowButton { name: Some(button_name.to_string()), @@ -3209,10 +3212,10 @@ mod tests { message_version: Some(1), message_params_json: None, }, - ), + )), ), ..Default::default() - })), + }), ..Default::default() } } @@ -3362,14 +3365,14 @@ mod tests { #[test] fn interactive_without_native_flow_returns_none() { let msg = wa::Message { - interactive_message: Some(Box::new(wa::message::InteractiveMessage { + interactive_message: buffa::MessageField::some(wa::message::InteractiveMessage { interactive_message: Some( interactive_message::InteractiveMessage::CollectionMessage( Default::default(), ), ), ..Default::default() - })), + }), ..Default::default() }; assert!(infer_biz_node(&msg, FIXED_NOW).is_none()); @@ -3379,18 +3382,18 @@ mod tests { #[test] fn native_flow_without_buttons_returns_none() { let msg = wa::Message { - interactive_message: Some(Box::new(wa::message::InteractiveMessage { + interactive_message: buffa::MessageField::some(wa::message::InteractiveMessage { interactive_message: Some( - interactive_message::InteractiveMessage::NativeFlowMessage( + interactive_message::InteractiveMessage::NativeFlowMessage(Box::new( NativeFlowMessage { buttons: vec![], message_version: Some(1), message_params_json: None, }, - ), + )), ), ..Default::default() - })), + }), ..Default::default() }; assert!(infer_biz_node(&msg, FIXED_NOW).is_none()); @@ -3400,9 +3403,9 @@ mod tests { #[test] fn button_without_name_returns_none() { let msg = wa::Message { - interactive_message: Some(Box::new(wa::message::InteractiveMessage { + interactive_message: buffa::MessageField::some(wa::message::InteractiveMessage { interactive_message: Some( - interactive_message::InteractiveMessage::NativeFlowMessage( + interactive_message::InteractiveMessage::NativeFlowMessage(Box::new( NativeFlowMessage { buttons: vec![NativeFlowButton { name: None, @@ -3411,10 +3414,10 @@ mod tests { message_version: Some(1), message_params_json: None, }, - ), + )), ), ..Default::default() - })), + }), ..Default::default() }; assert!(infer_biz_node(&msg, FIXED_NOW).is_none()); @@ -3425,9 +3428,9 @@ mod tests { #[test] fn document_with_caption_wrapper() { let inner = wa::Message { - interactive_message: Some(Box::new(wa::message::InteractiveMessage { + interactive_message: buffa::MessageField::some(wa::message::InteractiveMessage { interactive_message: Some( - interactive_message::InteractiveMessage::NativeFlowMessage( + interactive_message::InteractiveMessage::NativeFlowMessage(Box::new( NativeFlowMessage { buttons: vec![NativeFlowButton { name: Some("quick_reply".into()), @@ -3436,16 +3439,18 @@ mod tests { message_version: Some(1), message_params_json: None, }, - ), + )), ), ..Default::default() - })), + }), ..Default::default() }; let msg = wa::Message { - document_with_caption_message: Some(Box::new(wa::message::FutureProofMessage { - message: Some(Box::new(inner)), - })), + document_with_caption_message: buffa::MessageField::some( + wa::message::FutureProofMessage { + message: buffa::MessageField::some(inner), + }, + ), ..Default::default() }; let biz = infer_biz_node(&msg, FIXED_NOW) @@ -3768,8 +3773,8 @@ mod tests { .await; } - fn peer_test_account_proto() -> wa::AdvSignedDeviceIdentity { - wa::AdvSignedDeviceIdentity { + fn peer_test_account_proto() -> wa::ADVSignedDeviceIdentity { + wa::ADVSignedDeviceIdentity { details: Some(vec![0u8; 32]), account_signature_key: Some(vec![0u8; 32]), account_signature: Some(vec![0u8; 64]), @@ -3828,18 +3833,16 @@ mod tests { fn pdo_request_message(request_type: wa::message::PeerDataOperationRequestType) -> wa::Message { wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage { - r#type: Some( - wa::message::protocol_message::Type::PeerDataOperationRequestMessage as i32, - ), - peer_data_operation_request_message: Some( + protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage { + r#type: Some(wa::message::protocol_message::Type::PeerDataOperationRequestMessage), + peer_data_operation_request_message: buffa::MessageField::some( wa::message::PeerDataOperationRequestMessage { - peer_data_operation_request_type: Some(request_type as i32), + peer_data_operation_request_type: Some(request_type), ..Default::default() }, ), ..Default::default() - })), + }), ..Default::default() } } @@ -4262,7 +4265,7 @@ mod tests { /// and a text edit carries no mediatype. #[test] fn build_newsletter_edit_node_emits_plaintext_edit() { - use prost::Message as _; + use buffa::Message as _; let to: Jid = "120363000000000001@newsletter".parse().unwrap(); let content = wa::Message { conversation: Some("edited text".to_string()), @@ -4287,7 +4290,7 @@ mod tests { Some(wacore_binary::NodeContent::Bytes(b)) => b.clone(), other => panic!("expected plaintext bytes, got {other:?}"), }; - let decoded = wa::Message::decode(bytes.as_slice()).expect("decode plaintext"); + let decoded = wa::Message::decode_from_slice(bytes.as_slice()).expect("decode plaintext"); assert_eq!(decoded.conversation.as_deref(), Some("edited text")); } @@ -4296,10 +4299,10 @@ mod tests { fn build_newsletter_edit_node_media_edit() { let to: Jid = "120363000000000001@newsletter".parse().unwrap(); let content = wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage { + image_message: buffa::MessageField::some(wa::message::ImageMessage { caption: Some("new caption".to_string()), ..Default::default() - })), + }), ..Default::default() }; let node = build_newsletter_edit_node(&to, "3EB0MEDIA", NewsletterEdit::Edit(&content)); diff --git a/src/store/signal.rs b/src/store/signal.rs index be0582db4..276037103 100644 --- a/src/store/signal.rs +++ b/src/store/signal.rs @@ -265,14 +265,14 @@ impl PreKeyStore for Device { &self, prekey_id: u32, ) -> Result, StoreError> { - use prost::Message; + use buffa::Message; use wacore::libsignal::protocol::KeyPair; use wacore::libsignal::store::record_helpers::new_pre_key_record; match self.backend.load_prekey(prekey_id).await { Ok(Some(bytes)) => { // Try new format first (protobuf-encoded PreKeyRecordStructure) - if let Ok(record) = PreKeyRecordStructure::decode(bytes.as_ref()) { + if let Ok(record) = PreKeyRecordStructure::decode_from_slice(bytes.as_ref()) { return Ok(Some(record)); } @@ -300,7 +300,7 @@ impl PreKeyStore for Device { record: PreKeyRecordStructure, uploaded: bool, ) -> Result<(), StoreError> { - use prost::Message; + use buffa::Message; let bytes = record.encode_to_vec(); self.backend .store_prekey(prekey_id, &bytes, uploaded) diff --git a/src/voip/facade.rs b/src/voip/facade.rs index 428cf687a..4339f5d2b 100644 --- a/src/voip/facade.rs +++ b/src/voip/facade.rs @@ -9,7 +9,6 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use log::warn; -use prost::Message as _; use wacore::message_processing::EncType; use wacore::messages::MessageUtils; use wacore::stanza::call::{CAPABILITY_OFFER, OfferDeviceKey, OfferParams, build_offer}; @@ -145,10 +144,11 @@ impl<'a> AcceptCall<'a> { .map_err(|e| CallError::Decrypt(e.to_string()))?; let unpadded = MessageUtils::unpad_message_ref(&plaintext, enc.version) .map_err(|e| CallError::Decrypt(e.to_string()))?; - let msg = wa::Message::decode(unpadded) + let msg = waproto::codec::message_decode(unpadded) .map_err(|e| CallError::Decrypt(format!("decode call message: {e}")))?; let call_key = msg .call + .into_option() .and_then(|c| c.call_key) .ok_or(CallError::Media("offer carried no callKey"))?; @@ -431,10 +431,10 @@ async fn place_call( // decrypting the per-device we send below. let call_key = rand::random::<[u8; 32]>(); let padded = MessageUtils::encode_and_pad(&wa::Message { - call: Some(Box::new(wa::message::Call { + call: buffa::MessageField::some(wa::message::Call { call_key: Some(call_key.to_vec()), ..Default::default() - })), + }), ..Default::default() }); @@ -1691,7 +1691,7 @@ mod tests { .await; // Set the ADV account so a pkmsg offer attaches a (as the send path does). pm.process_command(crate::store::commands::DeviceCommand::SetAccount(Some( - wa::AdvSignedDeviceIdentity { + wa::ADVSignedDeviceIdentity { details: Some(vec![0u8; 32]), account_signature_key: Some(vec![0u8; 32]), account_signature: Some(vec![0u8; 64]), @@ -2507,7 +2507,7 @@ mod tests { ))) .await; pm.process_command(crate::store::commands::DeviceCommand::SetAccount(Some( - wa::AdvSignedDeviceIdentity { + wa::ADVSignedDeviceIdentity { details: Some(vec![0u8; 32]), account_signature_key: Some(vec![0u8; 32]), account_signature: Some(vec![0u8; 64]), diff --git a/storages/sqlite-storage/Cargo.toml b/storages/sqlite-storage/Cargo.toml index 683f39aff..464f3d6d5 100644 --- a/storages/sqlite-storage/Cargo.toml +++ b/storages/sqlite-storage/Cargo.toml @@ -18,6 +18,7 @@ bundled-sqlite = ["libsqlite3-sys/bundled"] [dependencies] async-trait = { workspace = true } +buffa = { workspace = true } bytes = { workspace = true } diesel = { version = "2.3.10", default-features = false, features = [ "sqlite", @@ -29,7 +30,6 @@ diesel_migrations = { version = "2.3.2", default-features = false, features = [ ] } libsqlite3-sys = { version = "0.37", default-features = false, optional = true } log = { workspace = true } -prost = { workspace = true } # Shared across all r2d2 pools so each per-session store doesn't spawn its own pool of # management threads. Already in the tree transitively via r2d2 0.8. scheduled-thread-pool = "0.2" @@ -37,6 +37,10 @@ serde_json = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["sync", "rt", "time", "macros"] } wacore = { workspace = true } +[build-dependencies] +buffa-build = { workspace = true } +sha2 = { workspace = true } + [dev-dependencies] portable-atomic = { workspace = true } tokio = { workspace = true, features = ["sync", "rt", "time", "macros"] } diff --git a/storages/sqlite-storage/build.rs b/storages/sqlite-storage/build.rs new file mode 100644 index 000000000..20cf0c6b2 --- /dev/null +++ b/storages/sqlite-storage/build.rs @@ -0,0 +1,72 @@ +//! Generates Rust types for the on-disk wire format (`src/wire.rs` consumes +//! them) from the committed descriptor `proto/wire.desc`, compiled once from +//! `proto/wire.proto`. Reading the descriptor means consumers never need +//! `protoc`; editing the proto requires regenerating the descriptor via +//! `scripts/regenerate-wire-desc.sh`. + +fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed=proto/wire.desc"); + println!("cargo:rerun-if-changed=proto/wire.proto"); + println!("cargo:rerun-if-changed=proto/wire.desc.sha256"); + println!("cargo:rerun-if-changed=build.rs"); + + ensure_descriptor_fresh( + "proto/wire.proto", + "proto/wire.desc", + "proto/wire.desc.sha256", + "sqlite-storage (wire)", + "scripts/regenerate-wire-desc.sh", + )?; + + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR must be set by cargo"); + + buffa_build::Config::new() + .descriptor_set("proto/wire.desc") + .files(&["wire.proto"]) + .preserve_unknown_fields(false) + .out_dir(&out_dir) + .compile() +} + +/// Fail the build if the committed `.desc` no longer matches its `.proto` (proto +/// edited without rerunning the regenerate script), so codegen never silently +/// runs against a stale descriptor — for `wire.proto` that would ship an +/// incompatible on-disk blob layout. +fn ensure_descriptor_fresh( + proto: &str, + desc: &str, + sha: &str, + label: &str, + regen: &str, +) -> std::io::Result<()> { + use sha2::{Digest as _, Sha256}; + + let hex = |bytes: &[u8]| -> String { + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() + }; + + let (mut want_proto, mut want_desc) = (None, None); + for line in std::fs::read_to_string(sha)?.lines() { + let mut parts = line.split_whitespace(); + match (parts.next(), parts.next()) { + (Some("proto"), Some(h)) => want_proto = Some(h.to_owned()), + (Some("desc"), Some(h)) => want_desc = Some(h.to_owned()), + _ => {} + } + } + + let got_proto = hex(&std::fs::read(proto)?); + let got_desc = hex(&std::fs::read(desc)?); + + if want_proto.as_deref() != Some(&got_proto) || want_desc.as_deref() != Some(&got_desc) { + return Err(std::io::Error::other(format!( + "{label}: {proto}/{desc} do not match {sha}. Run `{regen}` and commit all three. \ + expected proto {want_proto:?}, desc {want_desc:?}; got proto {got_proto}, desc {got_desc}" + ))); + } + + Ok(()) +} diff --git a/storages/sqlite-storage/proto/wire.desc b/storages/sqlite-storage/proto/wire.desc new file mode 100644 index 000000000..d51755386 --- /dev/null +++ b/storages/sqlite-storage/proto/wire.desc @@ -0,0 +1,23 @@ + + + +wire.protowire"Y + NoiseCert +key ( Rkey + +not_before (R notBefore + not_after (RnotAfter"k +ServerCertChain3 + intermediate ( 2.wire.NoiseCertR intermediate# +leaf ( 2.wire.NoiseCertRleaf"p +AppStateSyncKeyWire +key_data ( RkeyData + fingerprint ( R fingerprint + timestamp (R timestamp" + HashStateWire +version (Rversion +hash ( RhashN +index_value_map ( 2&.wire.HashStateWire.IndexValueMapEntryR indexValueMap@ +IndexValueMapEntry +key ( Rkey +value ( Rvalue:8bproto3 \ No newline at end of file diff --git a/storages/sqlite-storage/proto/wire.desc.sha256 b/storages/sqlite-storage/proto/wire.desc.sha256 new file mode 100644 index 000000000..4a806d180 --- /dev/null +++ b/storages/sqlite-storage/proto/wire.desc.sha256 @@ -0,0 +1,2 @@ +proto b1b9fb21279283e7542f6ec8ec007b514c4a7879bf5e02255faceca771cc5d5a +desc c63a061afe99efaf399cddab56edbec836a4234079ac5c8be0fad4410c0bb68d diff --git a/storages/sqlite-storage/proto/wire.proto b/storages/sqlite-storage/proto/wire.proto new file mode 100644 index 000000000..15a1a264c --- /dev/null +++ b/storages/sqlite-storage/proto/wire.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; + +// On-disk encoding for the BLOB columns this backend persists (server cert +// chain, app-state sync keys, app-state hash state). Field-tagged so adding or +// reordering a field never corrupts existing rows. Wire-compatible with the +// rows previously written by the prost-derived equivalents (same field numbers +// and proto3 semantics), so old blobs keep decoding. +// +// Regenerate the descriptor after editing: scripts/regenerate-wire-desc.sh +package wire; + +message NoiseCert { + bytes key = 1; + int64 not_before = 2; + int64 not_after = 3; +} + +message ServerCertChain { + NoiseCert intermediate = 1; + NoiseCert leaf = 2; +} + +message AppStateSyncKeyWire { + bytes key_data = 1; + bytes fingerprint = 2; + int64 timestamp = 3; +} + +message HashStateWire { + uint64 version = 1; + bytes hash = 2; + map index_value_map = 3; +} diff --git a/storages/sqlite-storage/src/wire.rs b/storages/sqlite-storage/src/wire.rs index 074abb261..23947cbb2 100644 --- a/storages/sqlite-storage/src/wire.rs +++ b/storages/sqlite-storage/src/wire.rs @@ -1,20 +1,33 @@ //! On-disk encoding for the BLOB columns this backend persists (server cert //! chain, app-state sync keys, app-state hash state). //! -//! Modeled as protobuf via `prost` derive macros (no `.proto` file), reusing the -//! `prost` dependency the workspace already pulls in for the wire protocol. The -//! format is field-tagged, so reordering or adding a field doesn't corrupt old -//! rows the way a positional codec would. Domain types in `wacore` stay -//! untouched; conversion happens only at this boundary. +//! Modeled as protobuf (`proto/wire.proto`, generated by `buffa` at build +//! time). The format is field-tagged, so reordering or adding a field doesn't +//! corrupt old rows the way a positional codec would, and it stays +//! wire-compatible with rows previously written by the prost-derived +//! equivalents (same field numbers, same proto3 semantics). Domain types in +//! `wacore` stay untouched; conversion happens only at this boundary. -use std::collections::HashMap; - -use prost::Message; +use buffa::Message as _; use wacore::appstate::hash::HashState; use wacore::store::device::{CachedNoiseCert, CachedServerCertChain}; use wacore::store::error::StoreError; use wacore::store::traits::AppStateSyncKey; +mod proto { + #![allow( + unused, + non_camel_case_types, + non_snake_case, + unreachable_patterns, + clippy::all, + clippy::pedantic, + clippy::nursery + )] + buffa::include_proto!("wire"); +} +use proto::{AppStateSyncKeyWire, HashStateWire, NoiseCert, ServerCertChain}; + /// X25519 public key length in `CachedNoiseCert`. const NOISE_KEY_LEN: usize = 32; /// App-state hash length in `HashState`. @@ -22,49 +35,11 @@ const HASH_STATE_LEN: usize = 128; /// App-state master key length (the HKDF input for `expand_app_state_keys`). const APP_STATE_KEY_LEN: usize = 32; -#[derive(Clone, PartialEq, prost::Message)] -struct NoiseCert { - #[prost(bytes = "vec", tag = "1")] - key: Vec, - #[prost(int64, tag = "2")] - not_before: i64, - #[prost(int64, tag = "3")] - not_after: i64, -} - -#[derive(Clone, PartialEq, prost::Message)] -struct ServerCertChain { - #[prost(message, optional, tag = "1")] - intermediate: Option, - #[prost(message, optional, tag = "2")] - leaf: Option, -} - -#[derive(Clone, PartialEq, prost::Message)] -struct AppStateSyncKeyWire { - #[prost(bytes = "vec", tag = "1")] - key_data: Vec, - #[prost(bytes = "vec", tag = "2")] - fingerprint: Vec, - #[prost(int64, tag = "3")] - timestamp: i64, -} - -#[derive(Clone, PartialEq, prost::Message)] -struct HashStateWire { - #[prost(uint64, tag = "1")] - version: u64, - #[prost(bytes = "vec", tag = "2")] - hash: Vec, - #[prost(map = "string, bytes", tag = "3")] - index_value_map: HashMap>, -} - fn bad_len(field: &str, expected: usize, got: usize) -> StoreError { StoreError::Serialization(format!("{field}: expected {expected} bytes, got {got}").into()) } -fn decode_err(e: prost::DecodeError) -> StoreError { +fn decode_err(e: buffa::DecodeError) -> StoreError { StoreError::Serialization(Box::new(e)) } @@ -95,14 +70,14 @@ fn noise_cert_from_wire(w: NoiseCert) -> Result { pub(crate) fn encode_server_cert_chain(c: &CachedServerCertChain) -> Vec { ServerCertChain { - intermediate: Some((&c.intermediate).into()), - leaf: Some((&c.leaf).into()), + intermediate: buffa::MessageField::some(NoiseCert::from(&c.intermediate)), + leaf: buffa::MessageField::some(NoiseCert::from(&c.leaf)), } .encode_to_vec() } pub(crate) fn decode_server_cert_chain(bytes: &[u8]) -> Result { - let w = ServerCertChain::decode(bytes).map_err(decode_err)?; + let w = ServerCertChain::decode_from_slice(bytes).map_err(decode_err)?; let intermediate = w.intermediate.ok_or_else(|| { StoreError::Serialization("server_cert_chain.intermediate missing".into()) })?; @@ -127,7 +102,7 @@ pub(crate) fn encode_app_state_sync_key(k: &AppStateSyncKey) -> Vec { } pub(crate) fn decode_app_state_sync_key(bytes: &[u8]) -> Result { - let w = AppStateSyncKeyWire::decode(bytes).map_err(decode_err)?; + let w = AppStateSyncKeyWire::decode_from_slice(bytes).map_err(decode_err)?; // An old bincode row (or a corrupt blob) can occasionally parse as protobuf // with garbage key material. Reject anything that isn't a 32-byte master // key so the caller treats it as absent and re-requests it, rather than @@ -152,13 +127,17 @@ pub(crate) fn encode_hash_state(s: &HashState) -> Vec { HashStateWire { version: s.version, hash: s.hash.to_vec(), - index_value_map: s.index_value_map.clone(), + index_value_map: s + .index_value_map + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), } .encode_to_vec() } pub(crate) fn decode_hash_state(bytes: &[u8]) -> Result { - let w = HashStateWire::decode(bytes).map_err(decode_err)?; + let w = HashStateWire::decode_from_slice(bytes).map_err(decode_err)?; let got = w.hash.len(); let hash: [u8; HASH_STATE_LEN] = w .hash @@ -167,13 +146,14 @@ pub(crate) fn decode_hash_state(bytes: &[u8]) -> Result { Ok(HashState { version: w.version, hash, - index_value_map: w.index_value_map, + index_value_map: w.index_value_map.into_iter().collect(), }) } #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; #[test] fn server_cert_chain_roundtrips() { @@ -197,12 +177,12 @@ mod tests { fn server_cert_chain_rejects_wrong_key_len() { // A wire blob whose key is not 32 bytes must error, not silently truncate. let bytes = ServerCertChain { - intermediate: Some(NoiseCert { + intermediate: buffa::MessageField::some(NoiseCert { key: vec![0u8; 5], not_before: 1, not_after: 2, }), - leaf: Some(NoiseCert { + leaf: buffa::MessageField::some(NoiseCert { key: vec![0u8; 32], not_before: 1, not_after: 2, @@ -273,7 +253,7 @@ mod tests { let bytes = HashStateWire { version: 1, hash: vec![0u8; 64], - index_value_map: HashMap::new(), + index_value_map: Default::default(), } .encode_to_vec(); assert!(decode_hash_state(&bytes).is_err()); diff --git a/tests/discrepancy_pocs.rs b/tests/discrepancy_pocs.rs index 53b1bb9e8..5a13c31cf 100644 --- a/tests/discrepancy_pocs.rs +++ b/tests/discrepancy_pocs.rs @@ -13,10 +13,10 @@ use waproto::whatsapp as wa; #[test] fn regression_a1_revoked_reaction_returns_sender_revoke() { let msg = wa::Message { - reaction_message: Some(Box::new(wa::message::ReactionMessage { + reaction_message: buffa::MessageField::some(wa::message::ReactionMessage { text: Some(String::new()), ..Default::default() - })), + }), ..Default::default() }; assert_eq!( @@ -28,14 +28,14 @@ fn regression_a1_revoked_reaction_returns_sender_revoke() { #[test] fn regression_a1_keep_in_chat_undo_returns_sender_revoke() { let msg = wa::Message { - keep_in_chat_message: Some(Box::new(wa::message::KeepInChatMessage { - key: Some(wa::MessageKey { + keep_in_chat_message: buffa::MessageField::some(wa::message::KeepInChatMessage { + key: buffa::MessageField::some(wa::MessageKey { from_me: Some(true), ..Default::default() }), - keep_type: Some(wa::KeepType::UndoKeepForAll as i32), + keep_type: Some(wa::KeepType::UNDO_KEEP_FOR_ALL), ..Default::default() - })), + }), ..Default::default() }; assert_eq!( @@ -47,12 +47,12 @@ fn regression_a1_keep_in_chat_undo_returns_sender_revoke() { #[test] fn regression_a1_secret_encrypted_message_edit_returns_message_edit() { let msg = wa::Message { - secret_encrypted_message: Some(Box::new(wa::message::SecretEncryptedMessage { + secret_encrypted_message: buffa::MessageField::some(wa::message::SecretEncryptedMessage { secret_enc_type: Some( - wa::message::secret_encrypted_message::SecretEncType::MessageEdit as i32, + wa::message::secret_encrypted_message::SecretEncType::MESSAGE_EDIT, ), ..Default::default() - })), + }), ..Default::default() }; assert_eq!( @@ -64,12 +64,10 @@ fn regression_a1_secret_encrypted_message_edit_returns_message_edit() { #[test] fn regression_a1_secret_encrypted_event_edit_returns_message_edit() { let msg = wa::Message { - secret_encrypted_message: Some(Box::new(wa::message::SecretEncryptedMessage { - secret_enc_type: Some( - wa::message::secret_encrypted_message::SecretEncType::EventEdit as i32, - ), + secret_encrypted_message: buffa::MessageField::some(wa::message::SecretEncryptedMessage { + secret_enc_type: Some(wa::message::secret_encrypted_message::SecretEncType::EVENT_EDIT), ..Default::default() - })), + }), ..Default::default() }; assert_eq!( @@ -105,7 +103,8 @@ fn regression_a5_useragent_phone_id_is_omitted_by_default() { let mut device = Device::new(); device.pn = Some("5511999999999@s.whatsapp.net".parse().unwrap()); - let user_agent = device.get_client_payload().user_agent.unwrap(); + let payload = device.get_client_payload(); + let user_agent = payload.user_agent.as_option().unwrap(); assert!( user_agent.phone_id.is_none(), "phone_id must stay unset on the wire (WA Web never assigns UserAgent.phoneId)" @@ -122,7 +121,12 @@ fn regression_a5_useragent_phone_id_can_be_overridden() { device.pn = Some("5511999999999@s.whatsapp.net".parse().unwrap()); assert_eq!( - device.get_client_payload().user_agent.unwrap().phone_id, + device + .get_client_payload() + .user_agent + .into_option() + .unwrap() + .phone_id, Some("deadbeef-0000-0000-0000-000000000000".to_string()), ); } @@ -131,7 +135,8 @@ fn regression_a5_useragent_phone_id_can_be_overridden() { fn regression_a5_useragent_locale_is_configurable_and_default_is_country_code() { let mut device = Device::new(); device.pn = Some("5511999999999@s.whatsapp.net".parse().unwrap()); - let ua = device.get_client_payload().user_agent.unwrap(); + let payload = device.get_client_payload(); + let ua = payload.user_agent.as_option().unwrap(); assert_eq!(ua.locale_language_iso6391.as_deref(), Some("en")); assert_eq!(ua.locale_country_iso31661_alpha2.as_deref(), Some("US")); @@ -141,7 +146,8 @@ fn regression_a5_useragent_locale_is_configurable_and_default_is_country_code() let mut device = Device::new(); device.set_client_profile(profile); device.pn = Some("5511999999999@s.whatsapp.net".parse().unwrap()); - let ua = device.get_client_payload().user_agent.unwrap(); + let payload = device.get_client_payload(); + let ua = payload.user_agent.as_option().unwrap(); assert_eq!(ua.locale_language_iso6391.as_deref(), Some("pt")); assert_eq!(ua.locale_country_iso31661_alpha2.as_deref(), Some("BR")); } @@ -227,7 +233,7 @@ fn wa_web_value_mac( fn regression_a7_content_mac_matches_wa_web_at_short_key_id() { use wacore::appstate::hash::generate_content_mac; - let op = wa::syncd_mutation::SyncdOperation::Set; + let op = wa::syncd_mutation::SyncdOperation::SET; let key = [7u8; 32]; let key_id = vec![0u8, 0, 0, 0, 42, 1]; // 6 bytes, ad.length = 7 let data = b"some-value"; @@ -243,7 +249,7 @@ fn regression_a7_content_mac_matches_wa_web_at_wrap_boundary() { // ad.length = 256: WA Web encodes octet[7] = 0; the pre-fix Rust code // encoded [0,0,0,0,0,0,1,0] (256 BE), which differed. - let op = wa::syncd_mutation::SyncdOperation::Set; + let op = wa::syncd_mutation::SyncdOperation::SET; let key = [9u8; 32]; let key_id = vec![0xAA; 255]; let data = b"x"; diff --git a/tests/e2e/Cargo.toml b/tests/e2e/Cargo.toml index 4e044dd4d..e1696b82f 100644 --- a/tests/e2e/Cargo.toml +++ b/tests/e2e/Cargo.toml @@ -36,6 +36,7 @@ whatsapp-rust-ureq-http-client = { path = "../../http_clients/ureq-client", feat ] } [dev-dependencies] +buffa = { workspace = true } env_logger = { workspace = true } hex = { workspace = true } log = { workspace = true } diff --git a/tests/e2e/tests/media.rs b/tests/e2e/tests/media.rs index 8490c4b7b..131f033e8 100644 --- a/tests/e2e/tests/media.rs +++ b/tests/e2e/tests/media.rs @@ -31,7 +31,7 @@ impl From<&UploadResponse> for UploadedMediaParts { fn build_image_message(upload: &UploadResponse, caption: Option<&str>) -> wa::Message { let upload = UploadedMediaParts::from(upload); wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage { + image_message: buffa::MessageField::some(wa::message::ImageMessage { url: Some(upload.url), direct_path: Some(upload.direct_path), media_key: Some(upload.media_key), @@ -41,7 +41,7 @@ fn build_image_message(upload: &UploadResponse, caption: Option<&str>) -> wa::Me mimetype: Some("image/jpeg".to_string()), caption: caption.map(|c| c.to_string()), ..Default::default() - })), + }), ..Default::default() } } @@ -54,7 +54,7 @@ fn build_video_message( ) -> wa::Message { let upload = UploadedMediaParts::from(upload); wa::Message { - video_message: Some(Box::new(wa::message::VideoMessage { + video_message: buffa::MessageField::some(wa::message::VideoMessage { url: Some(upload.url), direct_path: Some(upload.direct_path), media_key: Some(upload.media_key), @@ -65,7 +65,7 @@ fn build_video_message( seconds: Some(seconds), caption: caption.map(|c| c.to_string()), ..Default::default() - })), + }), ..Default::default() } } @@ -74,7 +74,7 @@ fn build_video_message( fn build_document_message(upload: &UploadResponse, filename: &str, mimetype: &str) -> wa::Message { let upload = UploadedMediaParts::from(upload); wa::Message { - document_message: Some(Box::new(wa::message::DocumentMessage { + document_message: buffa::MessageField::some(wa::message::DocumentMessage { url: Some(upload.url), direct_path: Some(upload.direct_path), media_key: Some(upload.media_key), @@ -84,7 +84,7 @@ fn build_document_message(upload: &UploadResponse, filename: &str, mimetype: &st mimetype: Some(mimetype.to_string()), file_name: Some(filename.to_string()), ..Default::default() - })), + }), ..Default::default() } } @@ -93,7 +93,7 @@ fn build_document_message(upload: &UploadResponse, filename: &str, mimetype: &st fn build_audio_message(upload: &UploadResponse, ptt: bool, seconds: u32) -> wa::Message { let upload = UploadedMediaParts::from(upload); wa::Message { - audio_message: Some(Box::new(wa::message::AudioMessage { + audio_message: buffa::MessageField::some(wa::message::AudioMessage { url: Some(upload.url), direct_path: Some(upload.direct_path), media_key: Some(upload.media_key), @@ -108,7 +108,7 @@ fn build_audio_message(upload: &UploadResponse, ptt: bool, seconds: u32) -> wa:: ptt: Some(ptt), seconds: Some(seconds), ..Default::default() - })), + }), ..Default::default() } } @@ -415,12 +415,12 @@ async fn test_send_image_message() -> anyhow::Result<()> { let event = client_b .wait_for_event( 30, - |e| matches!(e, Event::Message(m, _) if m.image_message.is_some()), + |e| matches!(e, Event::Message(m, _) if m.image_message.is_set()), ) .await?; if let Event::Message(msg, info) = &*event { - let img = msg.image_message.as_ref().unwrap(); + let img = msg.image_message.as_option().unwrap(); assert_eq!(img.caption.as_deref(), Some(caption)); assert_eq!(img.mimetype.as_deref(), Some("image/jpeg")); assert!(img.direct_path.is_some()); @@ -430,10 +430,7 @@ async fn test_send_image_message() -> anyhow::Result<()> { info!("B received image from {:?}", info.source); // B downloads the received image - let downloaded = client_b - .client - .download(img.as_ref() as &dyn Downloadable) - .await?; + let downloaded = client_b.client.download(img as &dyn Downloadable).await?; assert_eq!( downloaded, original, "Downloaded image should match original" @@ -472,20 +469,17 @@ async fn test_send_video_message() -> anyhow::Result<()> { let event = client_b .wait_for_event( 30, - |e| matches!(e, Event::Message(m, _) if m.video_message.is_some()), + |e| matches!(e, Event::Message(m, _) if m.video_message.is_set()), ) .await?; if let Event::Message(msg, _) = &*event { - let vid = msg.video_message.as_ref().unwrap(); + let vid = msg.video_message.as_option().unwrap(); assert_eq!(vid.caption.as_deref(), Some("Cool video")); assert_eq!(vid.seconds, Some(15)); assert_eq!(vid.mimetype.as_deref(), Some("video/mp4")); - let downloaded = client_b - .client - .download(vid.as_ref() as &dyn Downloadable) - .await?; + let downloaded = client_b.client.download(vid as &dyn Downloadable).await?; assert_eq!(downloaded, original); } else { panic!("Expected video Message event"); @@ -521,19 +515,16 @@ async fn test_send_document_message() -> anyhow::Result<()> { let event = client_b .wait_for_event( 30, - |e| matches!(e, Event::Message(m, _) if m.document_message.is_some()), + |e| matches!(e, Event::Message(m, _) if m.document_message.is_set()), ) .await?; if let Event::Message(msg, _) = &*event { - let doc = msg.document_message.as_ref().unwrap(); + let doc = msg.document_message.as_option().unwrap(); assert_eq!(doc.file_name.as_deref(), Some("report.pdf")); assert_eq!(doc.mimetype.as_deref(), Some("application/pdf")); - let downloaded = client_b - .client - .download(doc.as_ref() as &dyn Downloadable) - .await?; + let downloaded = client_b.client.download(doc as &dyn Downloadable).await?; assert_eq!(downloaded, original); } else { panic!("Expected document Message event"); @@ -569,19 +560,16 @@ async fn test_send_audio_message() -> anyhow::Result<()> { let event = client_b .wait_for_event( 30, - |e| matches!(e, Event::Message(m, _) if m.audio_message.is_some()), + |e| matches!(e, Event::Message(m, _) if m.audio_message.is_set()), ) .await?; if let Event::Message(msg, _) = &*event { - let audio = msg.audio_message.as_ref().unwrap(); + let audio = msg.audio_message.as_option().unwrap(); assert_eq!(audio.seconds, Some(30)); assert_eq!(audio.ptt, Some(false)); - let downloaded = client_b - .client - .download(audio.as_ref() as &dyn Downloadable) - .await?; + let downloaded = client_b.client.download(audio as &dyn Downloadable).await?; assert_eq!(downloaded, original); } else { panic!("Expected audio Message event"); @@ -617,20 +605,17 @@ async fn test_send_ptt_voice_message() -> anyhow::Result<()> { let event = client_b .wait_for_event( 30, - |e| matches!(e, Event::Message(m, _) if m.audio_message.is_some()), + |e| matches!(e, Event::Message(m, _) if m.audio_message.is_set()), ) .await?; if let Event::Message(msg, _) = &*event { - let audio = msg.audio_message.as_ref().unwrap(); + let audio = msg.audio_message.as_option().unwrap(); assert_eq!(audio.ptt, Some(true)); assert_eq!(audio.seconds, Some(5)); assert_eq!(audio.mimetype.as_deref(), Some("audio/ogg; codecs=opus")); - let downloaded = client_b - .client - .download(audio.as_ref() as &dyn Downloadable) - .await?; + let downloaded = client_b.client.download(audio as &dyn Downloadable).await?; assert_eq!(downloaded, original); } else { panic!("Expected PTT audio Message event"); @@ -674,16 +659,13 @@ async fn test_send_image_bidirectional() -> anyhow::Result<()> { let event = client_b .wait_for_event( 30, - |e| matches!(e, Event::Message(m, _) if m.image_message.is_some()), + |e| matches!(e, Event::Message(m, _) if m.image_message.is_set()), ) .await?; if let Event::Message(msg, _) = &*event { - let img = msg.image_message.as_ref().unwrap(); + let img = msg.image_message.as_option().unwrap(); assert_eq!(img.caption.as_deref(), Some("From A")); - let downloaded = client_b - .client - .download(img.as_ref() as &dyn Downloadable) - .await?; + let downloaded = client_b.client.download(img as &dyn Downloadable).await?; assert_eq!(downloaded, data_a); } @@ -700,16 +682,13 @@ async fn test_send_image_bidirectional() -> anyhow::Result<()> { let event = client_a .wait_for_event( 30, - |e| matches!(e, Event::Message(m, _) if m.image_message.is_some()), + |e| matches!(e, Event::Message(m, _) if m.image_message.is_set()), ) .await?; if let Event::Message(msg, _) = &*event { - let img = msg.image_message.as_ref().unwrap(); + let img = msg.image_message.as_option().unwrap(); assert_eq!(img.caption.as_deref(), Some("From B")); - let downloaded = client_a - .client - .download(img.as_ref() as &dyn Downloadable) - .await?; + let downloaded = client_a.client.download(img as &dyn Downloadable).await?; assert_eq!(downloaded, data_b); } @@ -745,15 +724,12 @@ async fn test_send_multiple_media_types() -> anyhow::Result<()> { let event = client_b .wait_for_event( 30, - |e| matches!(e, Event::Message(m, _) if m.image_message.is_some()), + |e| matches!(e, Event::Message(m, _) if m.image_message.is_set()), ) .await?; if let Event::Message(msg, _) = &*event { - let img = msg.image_message.as_ref().unwrap(); - let dl = client_b - .client - .download(img.as_ref() as &dyn Downloadable) - .await?; + let img = msg.image_message.as_option().unwrap(); + let dl = client_b.client.download(img as &dyn Downloadable).await?; assert_eq!(dl, img_data); } @@ -769,15 +745,12 @@ async fn test_send_multiple_media_types() -> anyhow::Result<()> { let event = client_b .wait_for_event( 30, - |e| matches!(e, Event::Message(m, _) if m.document_message.is_some()), + |e| matches!(e, Event::Message(m, _) if m.document_message.is_set()), ) .await?; if let Event::Message(msg, _) = &*event { - let doc = msg.document_message.as_ref().unwrap(); - let dl = client_b - .client - .download(doc.as_ref() as &dyn Downloadable) - .await?; + let doc = msg.document_message.as_option().unwrap(); + let dl = client_b.client.download(doc as &dyn Downloadable).await?; assert_eq!(dl, doc_data); } @@ -793,15 +766,12 @@ async fn test_send_multiple_media_types() -> anyhow::Result<()> { let event = client_b .wait_for_event( 30, - |e| matches!(e, Event::Message(m, _) if m.audio_message.is_some()), + |e| matches!(e, Event::Message(m, _) if m.audio_message.is_set()), ) .await?; if let Event::Message(msg, _) = &*event { - let audio = msg.audio_message.as_ref().unwrap(); - let dl = client_b - .client - .download(audio.as_ref() as &dyn Downloadable) - .await?; + let audio = msg.audio_message.as_option().unwrap(); + let dl = client_b.client.download(audio as &dyn Downloadable).await?; assert_eq!(dl, aud_data); } @@ -908,20 +878,17 @@ async fn test_send_image_no_caption() -> anyhow::Result<()> { let event = client_b .wait_for_event( 30, - |e| matches!(e, Event::Message(m, _) if m.image_message.is_some()), + |e| matches!(e, Event::Message(m, _) if m.image_message.is_set()), ) .await?; if let Event::Message(msg, _) = &*event { - let img = msg.image_message.as_ref().unwrap(); + let img = msg.image_message.as_option().unwrap(); assert!( img.caption.is_none() || img.caption.as_deref() == Some(""), "Caption should be absent or empty" ); - let downloaded = client_b - .client - .download(img.as_ref() as &dyn Downloadable) - .await?; + let downloaded = client_b.client.download(img as &dyn Downloadable).await?; assert_eq!(downloaded, original); } diff --git a/tests/e2e/tests/memory_soak.rs b/tests/e2e/tests/memory_soak.rs index 867f90e72..3842f4a47 100644 --- a/tests/e2e/tests/memory_soak.rs +++ b/tests/e2e/tests/memory_soak.rs @@ -241,10 +241,10 @@ fn make_text_msg(text: &str) -> wa::Message { fn make_large_msg(round: usize) -> wa::Message { let body = format!("large-msg-r{round}-{}", "X".repeat(2000)); wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some(body), ..Default::default() - })), + }), ..Default::default() } } @@ -264,7 +264,7 @@ async fn send_and_recv( msg.conversation.as_deref() == Some(t.as_str()) || msg .extended_text_message - .as_ref() + .as_option() .and_then(|ext| ext.text.as_deref()) .is_some_and(|txt| txt.starts_with(&t)) } @@ -287,7 +287,7 @@ async fn wait_for_group_msg( msg.conversation.as_deref() == Some(text.as_str()) || msg .extended_text_message - .as_ref() + .as_option() .and_then(|ext| ext.text.as_deref()) .is_some_and(|txt| txt.starts_with(&text)) } diff --git a/tests/e2e/tests/messaging.rs b/tests/e2e/tests/messaging.rs index 2a51eb66c..2167898df 100644 --- a/tests/e2e/tests/messaging.rs +++ b/tests/e2e/tests/messaging.rs @@ -93,15 +93,15 @@ async fn test_message_revoke() -> anyhow::Result<()> { let event = client_b .wait_for_event( 30, - |e| matches!(e, Event::Message(msg, _) if msg.protocol_message.is_some()), + |e| matches!(e, Event::Message(msg, _) if msg.protocol_message.is_set()), ) .await?; if let Event::Message(msg, _) = &*event { - let proto = msg.protocol_message.as_ref().unwrap(); + let proto = msg.protocol_message.as_option().unwrap(); assert_eq!( - proto.r#type(), - wa::message::protocol_message::Type::Revoke, + proto.r#type, + Some(wa::message::protocol_message::Type::REVOKE), "Should be a revoke protocol message" ); } diff --git a/tests/e2e/tests/newsletter.rs b/tests/e2e/tests/newsletter.rs index 38a8f6df2..7242ea265 100644 --- a/tests/e2e/tests/newsletter.rs +++ b/tests/e2e/tests/newsletter.rs @@ -274,7 +274,7 @@ async fn test_newsletter_send_media_message() -> anyhow::Result<()> { // Build and send an image message let message = wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage { + image_message: buffa::MessageField::some(wa::message::ImageMessage { url: Some(upload.url.clone()), direct_path: Some(upload.direct_path.clone()), media_key: Some(upload.media_key.to_vec()), @@ -284,7 +284,7 @@ async fn test_newsletter_send_media_message() -> anyhow::Result<()> { mimetype: Some("image/jpeg".to_string()), caption: Some("Newsletter image test".to_string()), ..Default::default() - })), + }), ..Default::default() }; @@ -312,7 +312,7 @@ async fn test_newsletter_send_media_message() -> anyhow::Result<()> { if let Some(ref decoded) = msg.message { assert!( - decoded.image_message.is_some(), + decoded.image_message.is_set(), "decoded message should contain image_message" ); } diff --git a/tests/e2e/tests/status.rs b/tests/e2e/tests/status.rs index c2de2805c..00aab97ce 100644 --- a/tests/e2e/tests/status.rs +++ b/tests/e2e/tests/status.rs @@ -41,7 +41,7 @@ async fn status_broadcast_send_is_wa_web_compliant() -> anyhow::Result<()> { .send_text( "hello status", 0xFF1E_6E4F, - 0, + whatsapp_rust::waproto::whatsapp::message::extended_text_message::FontType::SYSTEM, &[recipient], Default::default(), ) @@ -82,7 +82,13 @@ async fn status_send_rejects_empty_recipients() -> anyhow::Result<()> { let err = client .client .status() - .send_text("no audience", 0xFF1E_6E4F, 0, &[], Default::default()) + .send_text( + "no audience", + 0xFF1E_6E4F, + whatsapp_rust::waproto::whatsapp::message::extended_text_message::FontType::SYSTEM, + &[], + Default::default(), + ) .await .expect_err("status with no recipients must error"); assert!( @@ -110,7 +116,7 @@ async fn status_send_rejects_non_user_recipient() -> anyhow::Result<()> { .send_text( "wrong audience", 0xFF1E_6E4F, - 0, + whatsapp_rust::waproto::whatsapp::message::extended_text_message::FontType::SYSTEM, &[group_jid], Default::default(), ) diff --git a/tests/handshake_integration.rs b/tests/handshake_integration.rs index 234665cb8..4fc7b5216 100644 --- a/tests/handshake_integration.rs +++ b/tests/handshake_integration.rs @@ -22,8 +22,8 @@ //! (i.e. it speaks IK shape, not XX shape) AND completes successfully. use async_trait::async_trait; +use buffa::Message; use bytes::Bytes; -use prost::Message; use std::sync::Arc; use std::sync::Mutex as StdMutex; use std::sync::atomic::AtomicU32; @@ -131,8 +131,8 @@ async fn xx_serve_full( let raw_hello = transport.sent.lock().unwrap()[0].to_vec(); let client_hello_bytes = parse_first_client_frame(&raw_hello); - let msg = wa::HandshakeMessage::decode(client_hello_bytes.as_slice()).unwrap(); - let client_eph_pub_vec = msg.client_hello.unwrap().ephemeral.unwrap(); + let msg = wa::HandshakeMessage::decode_from_slice(client_hello_bytes.as_slice()).unwrap(); + let client_eph_pub_vec = msg.client_hello.into_option().unwrap().ephemeral.unwrap(); let client_eph_pub: [u8; 32] = client_eph_pub_vec.try_into().unwrap(); let mut noise = NoiseHandshake::new(NOISE_PATTERN_XX, &WA_CONN_HEADER).unwrap(); @@ -151,7 +151,7 @@ async fn xx_serve_full( let encrypted_payload = noise.encrypt(&server.cert_chain_bytes).unwrap(); let server_hello = wa::HandshakeMessage { - server_hello: Some(wa::handshake_message::ServerHello { + server_hello: buffa::MessageField::some(wa::handshake_message::ServerHello { ephemeral: Some(server_eph_pub.to_vec()), r#static: Some(encrypted_static), payload: Some(encrypted_payload), @@ -159,8 +159,7 @@ async fn xx_serve_full( }), ..Default::default() }; - let mut sh_bytes = Vec::new(); - server_hello.encode(&mut sh_bytes).unwrap(); + let sh_bytes = server_hello.encode_to_vec(); let framed = wacore::framing::encode_frame(&sh_bytes, None).unwrap(); events_tx .send(TransportEvent::DataReceived(framed.into())) @@ -186,8 +185,8 @@ async fn ik_serve_accept( let raw_hello = transport.sent.lock().unwrap()[0].to_vec(); let client_hello_bytes = parse_first_client_frame(&raw_hello); - let msg = wa::HandshakeMessage::decode(client_hello_bytes.as_slice()).unwrap(); - let ch = msg.client_hello.unwrap(); + let msg = wa::HandshakeMessage::decode_from_slice(client_hello_bytes.as_slice()).unwrap(); + let ch = msg.client_hello.into_option().unwrap(); let client_eph_pub: [u8; 32] = ch.ephemeral.unwrap().try_into().unwrap(); let encrypted_static = ch.r#static.unwrap(); let encrypted_payload = ch.payload.unwrap(); @@ -217,7 +216,7 @@ async fn ik_serve_accept( let encrypted_cert = noise.encrypt(&server.cert_chain_bytes).unwrap(); let server_hello = wa::HandshakeMessage { - server_hello: Some(wa::handshake_message::ServerHello { + server_hello: buffa::MessageField::some(wa::handshake_message::ServerHello { ephemeral: Some(server_eph_pub.to_vec()), r#static: None, payload: Some(encrypted_cert), @@ -225,8 +224,7 @@ async fn ik_serve_accept( }), ..Default::default() }; - let mut sh_bytes = Vec::new(); - server_hello.encode(&mut sh_bytes).unwrap(); + let sh_bytes = server_hello.encode_to_vec(); let framed = wacore::framing::encode_frame(&sh_bytes, None).unwrap(); events_tx .send(TransportEvent::DataReceived(framed.into())) @@ -378,8 +376,8 @@ async fn cold_start_xx_then_cached_ik_reconnect() { let sent = transport2.sent.lock().unwrap(); let raw = sent[0].to_vec(); let body = parse_first_client_frame(&raw); - let parsed = wa::HandshakeMessage::decode(body.as_slice()).unwrap(); - let ch = parsed.client_hello.unwrap(); + let parsed = wa::HandshakeMessage::decode_from_slice(body.as_slice()).unwrap(); + let ch = parsed.client_hello.into_option().unwrap(); assert!( ch.r#static.is_some(), "IK ClientHello carries client static" @@ -399,7 +397,7 @@ async fn ik_serve_fallback_with_corrupt_payloads( let server_eph_pub: [u8; 32] = server_eph.public_key.public_key_bytes().try_into().unwrap(); let server_hello = wa::HandshakeMessage { - server_hello: Some(wa::handshake_message::ServerHello { + server_hello: buffa::MessageField::some(wa::handshake_message::ServerHello { ephemeral: Some(server_eph_pub.to_vec()), r#static: Some(vec![0xCC; 32 + 16]), payload: Some(vec![0xDE; 64]), @@ -407,8 +405,7 @@ async fn ik_serve_fallback_with_corrupt_payloads( }), ..Default::default() }; - let mut sh_bytes = Vec::new(); - server_hello.encode(&mut sh_bytes).unwrap(); + let sh_bytes = server_hello.encode_to_vec(); let framed = wacore::framing::encode_frame(&sh_bytes, None).unwrap(); events_tx .send(TransportEvent::DataReceived(framed.into())) @@ -678,9 +675,10 @@ async fn ik_serve_force_fallback_then_consume_finish( let raw_hello = transport.sent.lock().unwrap()[0].to_vec(); let client_hello_bytes = parse_first_client_frame(&raw_hello); - let msg = wa::HandshakeMessage::decode(client_hello_bytes.as_slice()).unwrap(); + let msg = wa::HandshakeMessage::decode_from_slice(client_hello_bytes.as_slice()).unwrap(); let client_eph_pub: [u8; 32] = msg .client_hello + .into_option() .unwrap() .ephemeral .unwrap() @@ -709,7 +707,7 @@ async fn ik_serve_force_fallback_then_consume_finish( let encrypted_cert = noise.encrypt(&server.cert_chain_bytes).unwrap(); let server_hello = wa::HandshakeMessage { - server_hello: Some(wa::handshake_message::ServerHello { + server_hello: buffa::MessageField::some(wa::handshake_message::ServerHello { ephemeral: Some(server_eph_pub.to_vec()), r#static: Some(encrypted_static), payload: Some(encrypted_cert), @@ -717,8 +715,7 @@ async fn ik_serve_force_fallback_then_consume_finish( }), ..Default::default() }; - let mut sh_bytes = Vec::new(); - server_hello.encode(&mut sh_bytes).unwrap(); + let sh_bytes = server_hello.encode_to_vec(); let framed = wacore::framing::encode_frame(&sh_bytes, None).unwrap(); events_tx .send(TransportEvent::DataReceived(framed.into())) @@ -837,7 +834,7 @@ async fn ik_with_stale_cache_invalidates_and_increments_counter() { let bogus_server_eph = KeyPair::generate(&mut rand::rng()).public_key; let bogus_server_eph_bytes: [u8; 32] = bogus_server_eph.public_key_bytes().try_into().unwrap(); let server_hello = wa::HandshakeMessage { - server_hello: Some(wa::handshake_message::ServerHello { + server_hello: buffa::MessageField::some(wa::handshake_message::ServerHello { ephemeral: Some(bogus_server_eph_bytes.to_vec()), r#static: None, // `payload` here is just garbage — AEAD MAC check will fail. @@ -846,8 +843,7 @@ async fn ik_with_stale_cache_invalidates_and_increments_counter() { }), ..Default::default() }; - let mut sh_bytes = Vec::new(); - server_hello.encode(&mut sh_bytes).unwrap(); + let sh_bytes = server_hello.encode_to_vec(); let framed = wacore::framing::encode_frame(&sh_bytes, None).unwrap(); let transport_for_task = transport.clone(); diff --git a/wacore/Cargo.toml b/wacore/Cargo.toml index 0a9ca84b1..b52823e2e 100644 --- a/wacore/Cargo.toml +++ b/wacore/Cargo.toml @@ -34,7 +34,7 @@ danger-skip-cert-chain-verify = ["wacore-noise/danger-skip-cert-chain-verify"] js = ["getrandom/wasm_js"] # VoIP/calls media crypto (SRTP, SFrame, WAHKDF). Pure, no-Tokio. Off by default: # the media plane is opt-in and pulls aes-gcm into the non-dev build. The MLow codec's -# runtime constant tables are zlib-compressed protobuf (prost, see tables.proto). +# runtime constant tables are zlib-compressed protobuf (buffa, see tables.proto). voip = ["dep:aes-gcm"] # Heap profiling of the codec hot paths via the `voip_profile` example (dhat as global allocator). # Dev/profiling only; off by default and not pulled into normal builds. @@ -48,6 +48,7 @@ async-channel = { workspace = true } async-lock = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } +buffa = { workspace = true } bytes = { workspace = true } chrono = { workspace = true, features = ["now", "serde"] } compact_str = { workspace = true } @@ -65,7 +66,6 @@ log = { workspace = true } md5 = "0.8.0" metrics = { workspace = true, optional = true } portable-atomic = { workspace = true } -prost = { workspace = true } rand = { workspace = true } serde = { workspace = true } serde-big-array = { workspace = true } @@ -84,6 +84,10 @@ wacore-libsignal = { workspace = true } wacore-noise = { workspace = true } waproto = { workspace = true } +[build-dependencies] +buffa-build = { workspace = true } +sha2 = { workspace = true } + [dev-dependencies] divan = { workspace = true } futures = { workspace = true, features = ["executor", "thread-pool"] } diff --git a/wacore/appstate/Cargo.toml b/wacore/appstate/Cargo.toml index ec270f6ff..301387262 100644 --- a/wacore/appstate/Cargo.toml +++ b/wacore/appstate/Cargo.toml @@ -16,12 +16,12 @@ simd = ["wacore-binary/simd"] [dependencies] anyhow = { workspace = true } +buffa = { workspace = true } bytemuck = { workspace = true } hex = { workspace = true } hkdf = { workspace = true } hmac = { workspace = true } log = { workspace = true } -prost = { workspace = true } serde = { workspace = true } serde-big-array = { workspace = true } serde_json = { workspace = true, features = ["alloc"] } diff --git a/wacore/appstate/benches/appstate_benchmark.rs b/wacore/appstate/benches/appstate_benchmark.rs index 1b6df9861..d8ee17a9c 100644 --- a/wacore/appstate/benches/appstate_benchmark.rs +++ b/wacore/appstate/benches/appstate_benchmark.rs @@ -64,9 +64,10 @@ fn setup_patch(n: usize) -> PatchFixture { let index = format!("[\"star\",\"5511{i:09}@s.whatsapp.net\"]"); let value = wa::SyncActionValue { timestamp: Some(1_700_000_000 + i as i64), - star_action: Some(wa::sync_action_value::StarAction { + star_action: wa::sync_action_value::StarAction { starred: Some(i % 2 == 0), - }), + } + .into(), ..Default::default() }; let iv = [i as u8; 16]; @@ -81,8 +82,8 @@ fn setup_patch(n: usize) -> PatchFixture { ); // Half the indices have a stored previous value the prev-lookup hits. if i % 2 == 0 - && let Some(rec) = &mutation.record - && let Some(idx) = rec.index.as_ref().and_then(|x| x.blob.clone()) + && let Some(rec) = mutation.record.as_option() + && let Some(idx) = rec.index.as_option().and_then(|x| x.blob.clone()) { prev_macs.insert(idx, vec![0x55u8; 32]); } @@ -99,11 +100,12 @@ fn setup_patch(n: usize) -> PatchFixture { state.version = 0; let mut patch = wa::SyncdPatch { - version: Some(wa::SyncdVersion { version: Some(1) }), + version: wa::SyncdVersion { version: Some(1) }.into(), mutations, - key_id: Some(wa::KeyId { + key_id: wa::KeyId { id: Some(key_id.clone()), - }), + } + .into(), snapshot_mac: Some(snapshot_mac), ..Default::default() }; @@ -165,9 +167,10 @@ fn setup_record( .to_string(), wa::SyncActionValue { timestamp: Some(1_700_000_000), - star_action: Some(wa::sync_action_value::StarAction { + star_action: wa::sync_action_value::StarAction { starred: Some(true), - }), + } + .into(), ..Default::default() }, ), @@ -177,11 +180,12 @@ fn setup_record( "[\"contact\",\"5511999998888@s.whatsapp.net\"]".to_string(), wa::SyncActionValue { timestamp: Some(1_700_000_000), - contact_action: Some(wa::sync_action_value::ContactAction { + contact_action: wa::sync_action_value::ContactAction { full_name: Some("Benchmark Contact Full Name".to_string()), first_name: Some("Benchmark".to_string()), ..Default::default() - }), + } + .into(), ..Default::default() }, ), diff --git a/wacore/appstate/src/decode.rs b/wacore/appstate/src/decode.rs index f6c561521..ba26f545b 100644 --- a/wacore/appstate/src/decode.rs +++ b/wacore/appstate/src/decode.rs @@ -1,7 +1,7 @@ use crate::AppStateError; use crate::hash::{generate_content_mac, validate_index_mac}; use crate::keys::ExpandedAppStateKeys; -use prost::Message; +use buffa::Message; use wacore_libsignal::crypto::aes_256_cbc_decrypt_into; use waproto::whatsapp as wa; @@ -49,8 +49,8 @@ pub fn decode_record( ) -> Result<(Mutation, RecordMacs), AppStateError> { let value_blob = record .value + .blob .as_ref() - .and_then(|v| v.blob.as_ref()) .ok_or(AppStateError::MissingValueBlob)?; if value_blob.len() < 16 + 32 { @@ -76,7 +76,10 @@ pub fn decode_record( aes_256_cbc_decrypt_into(ciphertext, &keys.value_encryption, iv, &mut plaintext) .map_err(|_| AppStateError::DecryptionFailed)?; - let action = wa::SyncActionData::decode(plaintext.as_slice()) + // Owned decode (not a view): the `value` sub-message is needed owned, so a + // view would parse it once into a view and copy it again into the owned + // form — two passes over the largest field. Owned decode does it in one. + let action = wa::SyncActionData::decode_from_slice(plaintext.as_slice()) .map_err(|_| AppStateError::DecodeFailed)?; // WA Web (syncdDecryptMutation) computes the index MAC unconditionally over the @@ -85,14 +88,14 @@ pub fn decode_record( if validate_macs { let stored = record .index - .as_ref() + .as_option() .and_then(|i| i.blob.as_ref()) .ok_or(AppStateError::MissingIndexMAC)?; validate_index_mac(action.index.as_deref().unwrap_or(&[]), stored, &keys.index)?; } let mut index_list: Vec = Vec::new(); - if let Some(idx_bytes) = action.index.as_ref() + if let Some(idx_bytes) = action.index.as_deref() && let Ok(parsed) = serde_json::from_slice::>(idx_bytes) { index_list = parsed; @@ -102,12 +105,12 @@ pub fn decode_record( // (previously unwrap_or_default() let this through when validate_macs=false). let index_mac = record .index - .as_ref() - .and_then(|i| i.blob.clone()) + .blob + .clone() .ok_or(AppStateError::MissingIndexMAC)?; Ok(( Mutation { - action_value: action.value, + action_value: action.value.into_option(), index: index_list, operation, }, @@ -126,37 +129,40 @@ pub fn collect_key_ids_from_patch_list( snapshot: Option<&wa::SyncdSnapshot>, patches: &[wa::SyncdPatch], ) -> Vec> { - use std::collections::HashSet; - - let mut seen = HashSet::new(); - let mut key_ids = Vec::new(); + collect_key_id_refs_from_patch_list(snapshot, patches) + .into_iter() + .map(<[u8]>::to_vec) + .collect() +} - let mut check = |key_id: Option<&Vec>| { - if let Some(k) = key_id - && !seen.contains(k.as_slice()) - { - // Unique key ID: two owned buffers are allocated via k.clone() and - // owned.clone() — one stored in `seen` for future dedup checks, one - // pushed to `key_ids` as the result. Duplicate key IDs are skipped - // by the seen.contains() check above, avoiding any allocation. - let owned = k.clone(); - seen.insert(owned.clone()); - key_ids.push(owned); +/// Borrowing variant for callers that only need to look up keys immediately. +pub fn collect_key_id_refs_from_patch_list<'a>( + snapshot: Option<&'a wa::SyncdSnapshot>, + patches: &'a [wa::SyncdPatch], +) -> Vec<&'a [u8]> { + fn push_unique<'a>(key_ids: &mut Vec<&'a [u8]>, key_id: Option<&'a Vec>) { + if let Some(k) = key_id { + let k = k.as_slice(); + if !key_ids.contains(&k) { + key_ids.push(k); + } } - }; + } + + let mut key_ids = Vec::new(); if let Some(snapshot) = snapshot { - check(snapshot.key_id.as_ref().and_then(|k| k.id.as_ref())); + push_unique(&mut key_ids, snapshot.key_id.id.as_ref()); for rec in &snapshot.records { - check(rec.key_id.as_ref().and_then(|k| k.id.as_ref())); + push_unique(&mut key_ids, rec.key_id.id.as_ref()); } } for patch in patches { - check(patch.key_id.as_ref().and_then(|k| k.id.as_ref())); + push_unique(&mut key_ids, patch.key_id.id.as_ref()); for mutation in &patch.mutations { - if let Some(record) = &mutation.record { - check(record.key_id.as_ref().and_then(|k| k.id.as_ref())); + if mutation.record.is_set() { + push_unique(&mut key_ids, mutation.record.key_id.id.as_ref()); } } } @@ -169,7 +175,7 @@ mod tests { use super::*; use crate::hash::{generate_content_mac, generate_index_mac}; use crate::keys::expand_app_state_keys; - use prost::Message; + use buffa::Message; use wacore_libsignal::crypto::aes_256_cbc_encrypt_into; fn create_test_record( @@ -192,13 +198,13 @@ mod tests { let index_bytes = action_data.index.as_deref().unwrap_or(&[]); wa::SyncdRecord { - index: Some(wa::SyncdIndex { + index: buffa::MessageField::some(wa::SyncdIndex { blob: Some(generate_index_mac(index_bytes, &keys.index)), }), - value: Some(wa::SyncdValue { + value: buffa::MessageField::some(wa::SyncdValue { blob: Some(value_blob), }), - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id.to_vec()), }), } @@ -211,7 +217,7 @@ mod tests { let key_id = b"test_key_id".to_vec(); let action_data = wa::SyncActionData { - value: Some(wa::SyncActionValue { + value: buffa::MessageField::some(wa::SyncActionValue { timestamp: Some(1234567890), ..Default::default() }), @@ -219,14 +225,14 @@ mod tests { }; let record = create_test_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &keys, &key_id, &action_data, ); let (mutation, macs) = decode_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &record, &keys, &key_id, @@ -238,7 +244,7 @@ mod tests { mutation.action_value.as_ref().and_then(|v| v.timestamp), Some(1234567890) ); - assert_eq!(mutation.operation, wa::syncd_mutation::SyncdOperation::Set); + assert_eq!(mutation.operation, wa::syncd_mutation::SyncdOperation::SET); // MACs are returned separately and must carry the real bytes, not empty // or swapped values: index_mac is the HMAC of the (absent here) index. assert_eq!(macs.index_mac, generate_index_mac(&[], &keys.index)); @@ -253,7 +259,7 @@ mod tests { let key_id = b"test_key_id".to_vec(); let action_data = wa::SyncActionData { - value: Some(wa::SyncActionValue { + value: buffa::MessageField::some(wa::SyncActionValue { timestamp: Some(1234567890), ..Default::default() }), @@ -261,7 +267,7 @@ mod tests { }; let record = create_test_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &keys, &key_id, &action_data, @@ -269,7 +275,7 @@ mod tests { // No index field, but the stored index MAC matches the empty-index HMAC: passes. let result = decode_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &record, &keys, &key_id, @@ -285,26 +291,26 @@ mod tests { let key_id = b"test_key_id".to_vec(); let action_data = wa::SyncActionData { - value: Some(wa::SyncActionValue { + value: buffa::MessageField::some(wa::SyncActionValue { timestamp: Some(1234567890), ..Default::default() }), ..Default::default() }; let mut record = create_test_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &keys, &key_id, &action_data, ); // Tamper the stored index MAC: with no index field the old code skipped the // check entirely and accepted this; WA Web (and now we) reject it. - record.index = Some(wa::SyncdIndex { + record.index = buffa::MessageField::some(wa::SyncdIndex { blob: Some(vec![0xFF; 32]), }); let err = decode_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &record, &keys, &key_id, @@ -322,11 +328,11 @@ mod tests { let key_id_4 = vec![10, 11, 12]; let snapshot = wa::SyncdSnapshot { - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id_1.clone()), }), records: vec![wa::SyncdRecord { - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id_2.clone()), }), ..Default::default() @@ -335,12 +341,12 @@ mod tests { }; let patches = vec![wa::SyncdPatch { - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id_3.clone()), }), mutations: vec![wa::SyncdMutation { - record: Some(wa::SyncdRecord { - key_id: Some(wa::KeyId { + record: buffa::MessageField::some(wa::SyncdRecord { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id_4.clone()), }), ..Default::default() @@ -364,11 +370,11 @@ mod tests { let key_id = vec![1, 2, 3]; let snapshot = wa::SyncdSnapshot { - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id.clone()), }), records: vec![wa::SyncdRecord { - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id.clone()), }), ..Default::default() @@ -377,7 +383,7 @@ mod tests { }; let patches = vec![wa::SyncdPatch { - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id.clone()), }), ..Default::default() diff --git a/wacore/appstate/src/encode.rs b/wacore/appstate/src/encode.rs index 56e6bb7c5..2b01203f6 100644 --- a/wacore/appstate/src/encode.rs +++ b/wacore/appstate/src/encode.rs @@ -1,6 +1,6 @@ use crate::hash::generate_content_mac; use crate::keys::ExpandedAppStateKeys; -use prost::Message; +use buffa::Message; use wacore_libsignal::crypto::{CryptographicMac, aes_256_cbc_encrypt_into}; use waproto::whatsapp as wa; @@ -27,7 +27,7 @@ pub fn encode_record( // 1. Build SyncActionData let action_data = wa::SyncActionData { index: Some(index.to_vec()), - value: Some(value.clone()), + value: buffa::MessageField::some(value.clone()), padding: Some(vec![]), version: Some(version), }; @@ -60,20 +60,20 @@ pub fn encode_record( // 7. Build the record let record = wa::SyncdRecord { - index: Some(wa::SyncdIndex { + index: buffa::MessageField::some(wa::SyncdIndex { blob: Some(index_mac), }), - value: Some(wa::SyncdValue { + value: buffa::MessageField::some(wa::SyncdValue { blob: Some(value_blob), }), - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id.to_vec()), }), }; let mutation = wa::SyncdMutation { - operation: Some(operation as i32), - record: Some(record), + operation: Some(operation), + record: buffa::MessageField::some(record), }; (mutation, value_mac) @@ -94,7 +94,7 @@ mod tests { let index = b"[\"setting_pushName\"]"; let value = wa::SyncActionValue { - push_name_setting: Some(wa::sync_action_value::PushNameSetting { + push_name_setting: buffa::MessageField::some(wa::sync_action_value::PushNameSetting { name: Some("Test User".to_string()), }), timestamp: Some(1234567890), @@ -102,7 +102,7 @@ mod tests { }; let (mutation, _value_mac) = encode_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, index, &value, &keys, @@ -112,9 +112,9 @@ mod tests { ); // Decode the encoded record - let record = mutation.record.as_ref().unwrap(); + let record = &*mutation.record; let (decoded, _macs) = decode_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, record, &keys, key_id, @@ -130,11 +130,11 @@ mod tests { decoded .action_value .as_ref() - .and_then(|v| v.push_name_setting.as_ref()) + .and_then(|v| v.push_name_setting.as_option()) .and_then(|p| p.name.as_deref()), Some("Test User") ); assert_eq!(decoded.index, vec!["setting_pushName"]); - assert_eq!(decoded.operation, wa::syncd_mutation::SyncdOperation::Set); + assert_eq!(decoded.operation, wa::syncd_mutation::SyncdOperation::SET); } } diff --git a/wacore/appstate/src/hash.rs b/wacore/appstate/src/hash.rs index cf6e7b75f..3e3994329 100644 --- a/wacore/appstate/src/hash.rs +++ b/wacore/appstate/src/hash.rs @@ -53,8 +53,8 @@ impl HashState { fn index_mac_of(mutation: &wa::SyncdMutation) -> Option<&[u8]> { mutation .record - .as_ref() - .and_then(|r| r.index.as_ref()) + .as_option() + .and_then(|r| r.index.as_option()) .and_then(|idx| idx.blob.as_deref()) } let index_mode = mutations.iter().all(|m| index_mac_of(m).is_some()); @@ -68,7 +68,7 @@ impl HashState { if index_mode { for mutation in mutations { if mutation.operation.unwrap_or_default() - == wa::syncd_mutation::SyncdOperation::Remove as i32 + == wa::syncd_mutation::SyncdOperation::REMOVE && let Some(index_mac) = index_mac_of(mutation) { removed_in_patch.push(index_mac); @@ -83,11 +83,10 @@ impl HashState { for (i, mutation) in mutations.iter().enumerate() { let op = mutation.operation.unwrap_or_default(); - let is_set = op == wa::syncd_mutation::SyncdOperation::Set as i32; + let is_set = op == wa::syncd_mutation::SyncdOperation::SET; if is_set - && let Some(record) = &mutation.record - && let Some(value) = &record.value - && let Some(blob) = &value.blob + && mutation.record.is_set() + && let Some(blob) = &mutation.record.value.blob && blob.len() >= 32 { added.push(&blob[blob.len() - 32..]); @@ -99,7 +98,7 @@ impl HashState { match get_prev_set_value_mac(index_mac, i) { Ok(Some(prev)) => removed.push(prev), Ok(None) => { - if op == wa::syncd_mutation::SyncdOperation::Remove as i32 { + if op == wa::syncd_mutation::SyncdOperation::REMOVE { result.has_missing_remove = true; log::trace!( target: "AppState", @@ -128,8 +127,8 @@ impl HashState { .filter_map(|record| { record .value + .blob .as_ref() - .and_then(|v| v.blob.as_ref()) .filter(|blob| blob.len() >= 32) .map(|blob| &blob[blob.len() - 32..]) }) @@ -158,9 +157,8 @@ pub fn generate_patch_mac(patch: &wa::SyncdPatch, name: &str, key: &[u8], versio mac.update(sm); } for m in &patch.mutations { - if let Some(record) = &m.record - && let Some(val) = &record.value - && let Some(blob) = &val.blob + if m.record.is_set() + && let Some(blob) = &m.record.value.blob && blob.len() >= 32 { mac.update(&blob[blob.len() - 32..]); @@ -237,14 +235,20 @@ mod tests { blob }); + let value = if let Some(b) = value_blob { + buffa::MessageField::some(wa::SyncdValue { blob: Some(b) }) + } else { + buffa::MessageField::none() + }; + wa::SyncdMutation { - operation: Some(operation as i32), - record: Some(wa::SyncdRecord { - index: Some(wa::SyncdIndex { + operation: Some(operation), + record: buffa::MessageField::some(wa::SyncdRecord { + index: buffa::MessageField::some(wa::SyncdIndex { blob: Some(index_mac), }), - value: value_blob.map(|b| wa::SyncdValue { blob: Some(b) }), - key_id: Some(wa::KeyId { + value, + key_id: buffa::MessageField::some(wa::KeyId { id: Some(b"test_key_id".to_vec()), }), }), @@ -266,12 +270,12 @@ mod tests { let mut state = HashState::default(); let initial_mutations = vec![ create_mutation( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, INDEX_MAC_1.to_vec(), Some(VALUE_MAC_1.to_vec()), ), create_mutation( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, INDEX_MAC_2.to_vec(), Some(VALUE_MAC_2.to_vec()), ), @@ -295,12 +299,12 @@ mod tests { let update_and_remove_mutations = vec![ create_mutation( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, INDEX_MAC_1.to_vec(), Some(VALUE_MAC_3_OVERWRITE.to_vec()), ), create_mutation( - wa::syncd_mutation::SyncdOperation::Remove, + wa::syncd_mutation::SyncdOperation::REMOVE, INDEX_MAC_2.to_vec(), None, ), @@ -336,12 +340,12 @@ mod tests { let mutations = vec![ create_mutation( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, INDEX_MAC.to_vec(), Some(NEW_VALUE.to_vec()), ), create_mutation( - wa::syncd_mutation::SyncdOperation::Remove, + wa::syncd_mutation::SyncdOperation::REMOVE, INDEX_MAC.to_vec(), Some(PREV_VALUE.to_vec()), ), @@ -374,22 +378,22 @@ mod tests { const NEW_VALUE: &[u8] = &[20; 32]; let mut index_less = create_mutation( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, vec![], Some(vec![30; 32]), ); - if let Some(rec) = index_less.record.as_mut() { - rec.index = None; + if let Some(rec) = index_less.record.as_option_mut() { + rec.index = Default::default(); } let mutations = vec![ create_mutation( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, INDEX_MAC.to_vec(), Some(NEW_VALUE.to_vec()), ), create_mutation( - wa::syncd_mutation::SyncdOperation::Remove, + wa::syncd_mutation::SyncdOperation::REMOVE, INDEX_MAC.to_vec(), Some(PREV_VALUE.to_vec()), ), @@ -419,12 +423,12 @@ mod tests { let mutations = vec![ create_mutation( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, INDEX_MAC.to_vec(), Some(NEW_VALUE.to_vec()), ), create_mutation( - wa::syncd_mutation::SyncdOperation::Remove, + wa::syncd_mutation::SyncdOperation::REMOVE, INDEX_MAC.to_vec(), Some(NEW_VALUE.to_vec()), ), @@ -459,25 +463,23 @@ mod tests { blob2.extend_from_slice(&[0x33u8; 32]); let patch = wa::SyncdPatch { - version: Some(wa::SyncdVersion { + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(version), }), snapshot_mac: Some(snapshot_mac.clone()), mutations: vec![ wa::SyncdMutation { - operation: Some(wa::syncd_mutation::SyncdOperation::Set as i32), - record: Some(wa::SyncdRecord { - index: None, - value: Some(wa::SyncdValue { blob: Some(blob1) }), - key_id: None, + operation: Some(wa::syncd_mutation::SyncdOperation::SET), + record: buffa::MessageField::some(wa::SyncdRecord { + value: buffa::MessageField::some(wa::SyncdValue { blob: Some(blob1) }), + ..Default::default() }), }, wa::SyncdMutation { - operation: Some(wa::syncd_mutation::SyncdOperation::Set as i32), - record: Some(wa::SyncdRecord { - index: None, - value: Some(wa::SyncdValue { blob: Some(blob2) }), - key_id: None, + operation: Some(wa::syncd_mutation::SyncdOperation::SET), + record: buffa::MessageField::some(wa::SyncdRecord { + value: buffa::MessageField::some(wa::SyncdValue { blob: Some(blob2) }), + ..Default::default() }), }, ], diff --git a/wacore/appstate/src/lib.rs b/wacore/appstate/src/lib.rs index 1e4f32a2c..30859e677 100644 --- a/wacore/appstate/src/lib.rs +++ b/wacore/appstate/src/lib.rs @@ -9,7 +9,9 @@ pub mod patch_decode; pub mod processor; pub mod schemas; -pub use decode::{Mutation, collect_key_ids_from_patch_list, decode_record}; +pub use decode::{ + Mutation, collect_key_id_refs_from_patch_list, collect_key_ids_from_patch_list, decode_record, +}; pub use encode::encode_record; pub use errors::*; pub use keys::{ExpandedAppStateKeys, expand_app_state_keys}; diff --git a/wacore/appstate/src/patch_decode.rs b/wacore/appstate/src/patch_decode.rs index 19c5af6a2..452c8ad30 100644 --- a/wacore/appstate/src/patch_decode.rs +++ b/wacore/appstate/src/patch_decode.rs @@ -1,7 +1,7 @@ //! Patch list parsing (snapshot + patches) - partial port of Go appstate/decode.go use anyhow::{Result, anyhow}; -use prost::Message; +use buffa::Message; use std::str::FromStr; use wacore_binary::node::{Node, NodeRef}; use waproto::whatsapp as wa; @@ -136,7 +136,7 @@ fn parse_single_collection(collection: &Node) -> Result { let mut snapshot_ref = None; if let Some(snapshot_node) = collection.get_optional_child("snapshot") && let Some(wacore_binary::node::NodeContent::Bytes(raw)) = &snapshot_node.content - && let Ok(ext_ref) = wa::ExternalBlobReference::decode(raw.as_slice()) + && let Ok(ext_ref) = wa::ExternalBlobReference::decode_from_slice(raw.as_slice()) { snapshot_ref = Some(ext_ref); } @@ -153,7 +153,7 @@ fn parse_single_collection(collection: &Node) -> Result { if child.tag == "patch" && let Some(wacore_binary::node::NodeContent::Bytes(raw)) = &child.content { - match wa::SyncdPatch::decode(raw.as_slice()) { + match wa::SyncdPatch::decode_from_slice(raw.as_slice()) { Ok(p) => patches.push(p), Err(e) => return Err(anyhow!("failed to unmarshal patch: {e}")), } diff --git a/wacore/appstate/src/processor.rs b/wacore/appstate/src/processor.rs index 54af2b5f7..7fd318671 100644 --- a/wacore/appstate/src/processor.rs +++ b/wacore/appstate/src/processor.rs @@ -68,11 +68,7 @@ pub fn process_snapshot( where F: FnMut(&[u8]) -> Result, AppStateError>, { - let version = snapshot - .version - .as_ref() - .and_then(|v| v.version) - .unwrap_or(0); + let version = snapshot.version.version.unwrap_or(0); initial_state.version = version; // Update hash state directly from records (no cloning needed) @@ -94,7 +90,7 @@ where if validate_macs { let (Some(mac_expected), Some(key_id)) = ( snapshot.mac.as_ref(), - snapshot.key_id.as_ref().and_then(|k| k.id.as_ref()), + snapshot.key_id.as_option().and_then(|k| k.id.as_deref()), ) else { return Err(AppStateError::SnapshotMACMismatch); }; @@ -118,15 +114,11 @@ where let mut mutation_macs = Vec::with_capacity(snapshot.records.len()); for rec in &snapshot.records { - let key_id = rec - .key_id - .as_ref() - .and_then(|k| k.id.as_ref()) - .ok_or(AppStateError::MissingKeyId)?; + let key_id = rec.key_id.id.as_ref().ok_or(AppStateError::MissingKeyId)?; let keys = get_keys(key_id)?; let (mutation, macs) = decode_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, rec, &keys, key_id, @@ -183,7 +175,7 @@ where let original_hash_is_empty = state.hash == [0u8; 128]; let had_no_prior_state = original_version == 0 && original_hash_is_empty; - let patch_version = patch.version.as_ref().and_then(|v| v.version).unwrap_or(0); + let patch_version = patch.version.version.unwrap_or(0); // WA Web: validatePatchVersion — strict monotonic version check. // Patch version must be exactly local_version + 1. If not, WA Web throws @@ -211,16 +203,18 @@ where // the loop; the in-patch overlay only models SET-overwrite collapse and must // never feed a REMOVE (a REMOVE preceded by a SET on the same index would // otherwise subtract the in-patch value instead of the store's). - let is_remove = patch.mutations[idx].operation.unwrap_or_default() - == wa::syncd_mutation::SyncdOperation::Remove as i32; + let is_remove = matches!( + patch.mutations[idx].operation, + Some(wa::syncd_mutation::SyncdOperation::REMOVE) + ); let prev = if !is_remove && let Some(value_mac) = in_patch.get(index_mac) { Some(value_mac.to_vec()) } else { get_prev_value_mac(index_mac).map_err(|e| anyhow::anyhow!(e))? }; - if let Some(rec) = &patch.mutations[idx].record - && let Some(index) = rec.index.as_ref().and_then(|i| i.blob.as_deref()) - && let Some(value) = rec.value.as_ref().and_then(|v| v.blob.as_deref()) + if let Some(rec) = patch.mutations[idx].record.as_option() + && let Some(index) = rec.index.as_option().and_then(|i| i.blob.as_deref()) + && let Some(value) = rec.value.as_option().and_then(|v| v.blob.as_deref()) && value.len() >= 32 { in_patch.insert(index, &value[value.len() - 32..]); @@ -240,7 +234,7 @@ where ); // Validate MACs if requested - if validate_macs && let Some(key_id) = patch.key_id.as_ref().and_then(|k| k.id.as_ref()) { + if validate_macs && let Some(key_id) = patch.key_id.id.as_ref() { let keys = get_keys(key_id)?; validate_patch_macs( patch, @@ -266,27 +260,29 @@ where let mut removed_index_macs = Vec::with_capacity(patch.mutations.len()); for m in &patch.mutations { - if let Some(rec) = &m.record { - let op = wa::syncd_mutation::SyncdOperation::try_from(m.operation.unwrap_or(0)) - .unwrap_or(wa::syncd_mutation::SyncdOperation::Set); + if m.record.is_set() { + let op = m + .operation + .unwrap_or(wa::syncd_mutation::SyncdOperation::SET); - let key_id = rec + let key_id = m + .record .key_id + .id .as_ref() - .and_then(|k| k.id.as_ref()) .ok_or(AppStateError::MissingKeyId)?; let keys = get_keys(key_id)?; - let (mutation, macs) = decode_record(op, rec, &keys, key_id, validate_macs)?; + let (mutation, macs) = decode_record(op, &m.record, &keys, key_id, validate_macs)?; match op { - wa::syncd_mutation::SyncdOperation::Set => { + wa::syncd_mutation::SyncdOperation::SET => { added_macs.push(AppStateMutationMAC { index_mac: macs.index_mac, value_mac: macs.value_mac, }); } - wa::syncd_mutation::SyncdOperation::Remove => { + wa::syncd_mutation::SyncdOperation::REMOVE => { removed_index_macs.push(macs.index_mac); } } @@ -319,15 +315,18 @@ fn detect_duplicate_index_in_patch(mutations: &[wa::SyncdMutation]) -> Result<() let mut seen_set: Vec<&[u8]> = Vec::new(); let mut seen_remove: Vec<&[u8]> = Vec::new(); for m in mutations { - let Some(rec) = &m.record else { continue }; - let Some(index_mac) = rec.index.as_ref().and_then(|i| i.blob.as_deref()) else { + let Some(rec) = m.record.as_option() else { + continue; + }; + let Some(index_mac) = rec.index.as_option().and_then(|i| i.blob.as_deref()) else { continue; }; - let op = wa::syncd_mutation::SyncdOperation::try_from(m.operation.unwrap_or(0)) - .unwrap_or(wa::syncd_mutation::SyncdOperation::Set); + let op = m + .operation + .unwrap_or(wa::syncd_mutation::SyncdOperation::SET); let seen = match op { - wa::syncd_mutation::SyncdOperation::Set => &mut seen_set, - wa::syncd_mutation::SyncdOperation::Remove => &mut seen_remove, + wa::syncd_mutation::SyncdOperation::SET => &mut seen_set, + wa::syncd_mutation::SyncdOperation::REMOVE => &mut seen_remove, }; if seen.contains(&index_mac) { return Err(AppStateError::DuplicateIndexInPatch); @@ -395,7 +394,7 @@ pub fn validate_patch_macs( } if let Some(patch_mac) = patch.patch_mac.as_ref() { - let version = patch.version.as_ref().and_then(|v| v.version).unwrap_or(0); + let version = patch.version.version.unwrap_or(0); let computed_patch = generate_patch_mac(patch, collection_name, &keys.patch_mac, version); if computed_patch != *patch_mac { debug!( @@ -439,7 +438,7 @@ mod tests { use crate::hash::{generate_content_mac, generate_index_mac}; use crate::keys::expand_app_state_keys; use crate::lthash::WAPATCH_INTEGRITY; - use prost::Message; + use buffa::Message; use wacore_libsignal::crypto::aes_256_cbc_encrypt_into; fn create_encrypted_record( @@ -453,7 +452,7 @@ mod tests { // their HMAC, so the record stays valid under unconditional index-MAC checks. let action_data = wa::SyncActionData { index: Some(index_mac.to_vec()), - value: Some(wa::SyncActionValue { + value: buffa::MessageField::some(wa::SyncActionValue { timestamp: Some(timestamp), ..Default::default() }), @@ -473,13 +472,13 @@ mod tests { value_blob.extend_from_slice(&value_mac); wa::SyncdRecord { - index: Some(wa::SyncdIndex { + index: buffa::MessageField::some(wa::SyncdIndex { blob: Some(generate_index_mac(index_mac, &keys.index)), }), - value: Some(wa::SyncdValue { + value: buffa::MessageField::some(wa::SyncdValue { blob: Some(value_blob), }), - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id.to_vec()), }), } @@ -493,7 +492,7 @@ mod tests { let index_mac = vec![1; 32]; let record = create_encrypted_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &index_mac, &keys, &key_id, @@ -501,9 +500,9 @@ mod tests { ); let snapshot = wa::SyncdSnapshot { - version: Some(wa::SyncdVersion { version: Some(1) }), + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }), records: vec![record], - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id.clone()), }), ..Default::default() @@ -543,7 +542,7 @@ mod tests { let keys = expand_app_state_keys(&master_key); let key_id = b"test_key_id".to_vec(); let record = create_encrypted_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &[1u8; 32], &keys, &key_id, @@ -551,9 +550,9 @@ mod tests { ); // Snapshot WITHOUT a `mac` field — must fail validation, not be accepted. let snapshot = wa::SyncdSnapshot { - version: Some(wa::SyncdVersion { version: Some(1) }), + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }), records: vec![record], - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id.clone()), }), ..Default::default() @@ -571,7 +570,7 @@ mod tests { let keys = expand_app_state_keys(&master_key); let key_id = b"test_key_id".to_vec(); let record = create_encrypted_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &[1u8; 32], &keys, &key_id, @@ -579,10 +578,10 @@ mod tests { ); // mac present but top-level key_id absent — the other branch of the gate. let snapshot = wa::SyncdSnapshot { - version: Some(wa::SyncdVersion { version: Some(1) }), + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }), records: vec![record], mac: Some(vec![9u8; 32]), - key_id: None, + key_id: buffa::MessageField::none(), }; let get_keys = |_: &[u8]| Ok(Arc::new(keys.clone())); let mut state = HashState::default(); @@ -599,7 +598,7 @@ mod tests { let index_mac = vec![1; 32]; let record = create_encrypted_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &index_mac, &keys, &key_id, @@ -607,12 +606,12 @@ mod tests { ); let patch = wa::SyncdPatch { - version: Some(wa::SyncdVersion { version: Some(2) }), + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(2) }), mutations: vec![wa::SyncdMutation { - operation: Some(wa::syncd_mutation::SyncdOperation::Set as i32), - record: Some(record), + operation: Some(wa::syncd_mutation::SyncdOperation::SET), + record: buffa::MessageField::some(record), }], - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id.clone()), }), ..Default::default() @@ -646,7 +645,7 @@ mod tests { let master_key = [7u8; 32]; let keys = expand_app_state_keys(&master_key); let patch = wa::SyncdPatch { - version: Some(wa::SyncdVersion { version: Some(2) }), + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(2) }), snapshot_mac: Some(vec![0u8; 32]), ..Default::default() }; @@ -667,7 +666,7 @@ mod tests { let master_key = [7u8; 32]; let keys = expand_app_state_keys(&master_key); let patch = wa::SyncdPatch { - version: Some(wa::SyncdVersion { version: Some(2) }), + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(2) }), patch_mac: Some(vec![0u8; 32]), ..Default::default() }; @@ -692,9 +691,9 @@ mod tests { // Two SET mutations colliding on the same index within one patch. let mk = |ts| wa::SyncdMutation { - operation: Some(wa::syncd_mutation::SyncdOperation::Set as i32), - record: Some(create_encrypted_record( - wa::syncd_mutation::SyncdOperation::Set, + operation: Some(wa::syncd_mutation::SyncdOperation::SET), + record: buffa::MessageField::some(create_encrypted_record( + wa::syncd_mutation::SyncdOperation::SET, &index_mac, &keys, &key_id, @@ -702,9 +701,9 @@ mod tests { )), }; let patch = wa::SyncdPatch { - version: Some(wa::SyncdVersion { version: Some(1) }), + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }), mutations: vec![mk(1), mk(2)], - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id.clone()), }), ..Default::default() @@ -728,9 +727,9 @@ mod tests { // SET and REMOVE share an index legitimately: WA Web tracks the two // operations in separate sets, so this is not tampering. let set = wa::SyncdMutation { - operation: Some(wa::syncd_mutation::SyncdOperation::Set as i32), - record: Some(create_encrypted_record( - wa::syncd_mutation::SyncdOperation::Set, + operation: Some(wa::syncd_mutation::SyncdOperation::SET), + record: buffa::MessageField::some(create_encrypted_record( + wa::syncd_mutation::SyncdOperation::SET, &index_mac, &keys, &key_id, @@ -738,9 +737,9 @@ mod tests { )), }; let remove = wa::SyncdMutation { - operation: Some(wa::syncd_mutation::SyncdOperation::Remove as i32), - record: Some(create_encrypted_record( - wa::syncd_mutation::SyncdOperation::Remove, + operation: Some(wa::syncd_mutation::SyncdOperation::REMOVE), + record: buffa::MessageField::some(create_encrypted_record( + wa::syncd_mutation::SyncdOperation::REMOVE, &index_mac, &keys, &key_id, @@ -748,9 +747,9 @@ mod tests { )), }; let patch = wa::SyncdPatch { - version: Some(wa::SyncdVersion { version: Some(1) }), + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }), mutations: vec![set, remove], - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id.clone()), }), ..Default::default() @@ -773,9 +772,9 @@ mod tests { let key_id = b"test_key_id".to_vec(); let mk = |index: &[u8], ts| wa::SyncdMutation { - operation: Some(wa::syncd_mutation::SyncdOperation::Set as i32), - record: Some(create_encrypted_record( - wa::syncd_mutation::SyncdOperation::Set, + operation: Some(wa::syncd_mutation::SyncdOperation::SET), + record: buffa::MessageField::some(create_encrypted_record( + wa::syncd_mutation::SyncdOperation::SET, index, &keys, &key_id, @@ -783,9 +782,9 @@ mod tests { )), }; let patch = wa::SyncdPatch { - version: Some(wa::SyncdVersion { version: Some(1) }), + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }), mutations: vec![mk(&[3u8; 32], 1), mk(&[4u8; 32], 2)], - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id.clone()), }), ..Default::default() @@ -807,7 +806,7 @@ mod tests { // Create initial record let initial_record = create_encrypted_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &index_mac, &keys, &key_id, @@ -815,8 +814,6 @@ mod tests { ); let initial_value_blob = initial_record .value - .as_ref() - .expect("test data should be valid") .blob .as_ref() .expect("test data should be valid"); @@ -824,9 +821,9 @@ mod tests { // Process initial snapshot to get starting state let snapshot = wa::SyncdSnapshot { - version: Some(wa::SyncdVersion { version: Some(1) }), + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }), records: vec![initial_record], - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id.clone()), }), ..Default::default() @@ -840,7 +837,7 @@ mod tests { // Create overwrite record let overwrite_record = create_encrypted_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &index_mac, &keys, &key_id, @@ -848,12 +845,12 @@ mod tests { ); let patch = wa::SyncdPatch { - version: Some(wa::SyncdVersion { version: Some(2) }), + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(2) }), mutations: vec![wa::SyncdMutation { - operation: Some(wa::syncd_mutation::SyncdOperation::Set as i32), - record: Some(overwrite_record.clone()), + operation: Some(wa::syncd_mutation::SyncdOperation::SET), + record: buffa::MessageField::some(overwrite_record.clone()), }], - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id.clone()), }), ..Default::default() @@ -894,6 +891,7 @@ mod tests { // Verify the hash was updated correctly (old value removed, new added) let new_value_blob = overwrite_record .value + .into_option() .expect("test data should be valid") .blob .expect("test data should be valid"); @@ -921,14 +919,14 @@ mod tests { let index_mac = vec![1; 32]; let first = create_encrypted_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &index_mac, &keys, &key_id, 1000, ); let second = create_encrypted_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &index_mac, &keys, &key_id, @@ -936,7 +934,7 @@ mod tests { ); let tail = |rec: &wa::SyncdRecord| { - let blob = rec.value.as_ref().unwrap().blob.as_ref().unwrap(); + let blob = rec.value.as_option().unwrap().blob.as_ref().unwrap(); blob[blob.len() - 32..].to_vec() }; let first_tail = tail(&first); @@ -947,18 +945,18 @@ mod tests { ); let patch = wa::SyncdPatch { - version: Some(wa::SyncdVersion { version: Some(1) }), + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }), mutations: vec![ wa::SyncdMutation { - operation: Some(wa::syncd_mutation::SyncdOperation::Set as i32), - record: Some(first), + operation: Some(wa::syncd_mutation::SyncdOperation::SET), + record: buffa::MessageField::some(first), }, wa::SyncdMutation { - operation: Some(wa::syncd_mutation::SyncdOperation::Set as i32), - record: Some(second), + operation: Some(wa::syncd_mutation::SyncdOperation::SET), + record: buffa::MessageField::some(second), }, ], - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id.clone()), }), ..Default::default() @@ -1013,14 +1011,14 @@ mod tests { let store_prev = vec![9u8; 32]; let set = create_encrypted_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &index_mac, &keys, &key_id, 2000, ); let remove = create_encrypted_record( - wa::syncd_mutation::SyncdOperation::Remove, + wa::syncd_mutation::SyncdOperation::REMOVE, &index_mac, &keys, &key_id, @@ -1028,26 +1026,26 @@ mod tests { ); let tail = |rec: &wa::SyncdRecord| { - let blob = rec.value.as_ref().unwrap().blob.as_ref().unwrap(); + let blob = rec.value.as_option().unwrap().blob.as_ref().unwrap(); blob[blob.len() - 32..].to_vec() }; let set_tail = tail(&set); let build_patch = |mutations: Vec| wa::SyncdPatch { - version: Some(wa::SyncdVersion { version: Some(1) }), + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(1) }), mutations, - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id.clone()), }), ..Default::default() }; let set_mutation = wa::SyncdMutation { - operation: Some(wa::syncd_mutation::SyncdOperation::Set as i32), - record: Some(set), + operation: Some(wa::syncd_mutation::SyncdOperation::SET), + record: buffa::MessageField::some(set), }; let remove_mutation = wa::SyncdMutation { - operation: Some(wa::syncd_mutation::SyncdOperation::Remove as i32), - record: Some(remove), + operation: Some(wa::syncd_mutation::SyncdOperation::REMOVE), + record: buffa::MessageField::some(remove), }; let get_keys = |_: &[u8]| Ok(Arc::new(keys.clone())); @@ -1105,7 +1103,7 @@ mod tests { let index_mac = vec![99; 32]; let record = create_encrypted_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &index_mac, &keys, &key_id, @@ -1120,12 +1118,12 @@ mod tests { // Patch claims version 3 (rollback: 3 < 5 + 1) let patch = wa::SyncdPatch { - version: Some(wa::SyncdVersion { version: Some(3) }), + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(3) }), mutations: vec![wa::SyncdMutation { - operation: Some(wa::syncd_mutation::SyncdOperation::Set as i32), - record: Some(record), + operation: Some(wa::syncd_mutation::SyncdOperation::SET), + record: buffa::MessageField::some(record), }], - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id.clone()), }), ..Default::default() @@ -1159,7 +1157,7 @@ mod tests { let index_mac = vec![99; 32]; let record = create_encrypted_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &index_mac, &keys, &key_id, @@ -1174,12 +1172,12 @@ mod tests { // Patch claims version 8 (gap: 8 != 5 + 1) let patch = wa::SyncdPatch { - version: Some(wa::SyncdVersion { version: Some(8) }), + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(8) }), mutations: vec![wa::SyncdMutation { - operation: Some(wa::syncd_mutation::SyncdOperation::Set as i32), - record: Some(record), + operation: Some(wa::syncd_mutation::SyncdOperation::SET), + record: buffa::MessageField::some(record), }], - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id.clone()), }), ..Default::default() @@ -1212,7 +1210,7 @@ mod tests { let index_mac = vec![99; 32]; let record = create_encrypted_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &index_mac, &keys, &key_id, @@ -1227,12 +1225,12 @@ mod tests { // Patch version 6 (exactly local + 1) let patch = wa::SyncdPatch { - version: Some(wa::SyncdVersion { version: Some(6) }), + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(6) }), mutations: vec![wa::SyncdMutation { - operation: Some(wa::syncd_mutation::SyncdOperation::Set as i32), - record: Some(record), + operation: Some(wa::syncd_mutation::SyncdOperation::SET), + record: buffa::MessageField::some(record), }], - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id.clone()), }), ..Default::default() @@ -1257,7 +1255,7 @@ mod tests { let index_mac = vec![99; 32]; let record = create_encrypted_record( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &index_mac, &keys, &key_id, @@ -1269,12 +1267,12 @@ mod tests { // Patch version 42 — should be accepted since no prior state let patch = wa::SyncdPatch { - version: Some(wa::SyncdVersion { version: Some(42) }), + version: buffa::MessageField::some(wa::SyncdVersion { version: Some(42) }), mutations: vec![wa::SyncdMutation { - operation: Some(wa::syncd_mutation::SyncdOperation::Set as i32), - record: Some(record), + operation: Some(wa::syncd_mutation::SyncdOperation::SET), + record: buffa::MessageField::some(record), }], - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id.clone()), }), ..Default::default() diff --git a/wacore/benches/appstate_sync_benchmark.rs b/wacore/benches/appstate_sync_benchmark.rs index 7dff285a2..306d135f3 100644 --- a/wacore/benches/appstate_sync_benchmark.rs +++ b/wacore/benches/appstate_sync_benchmark.rs @@ -20,15 +20,15 @@ fn setup_mutations(n: usize) -> Vec { let mut index_mac = vec![0u8; 32]; index_mac[..8].copy_from_slice(&i.to_le_bytes()); wa::SyncdMutation { - operation: Some(wa::syncd_mutation::SyncdOperation::Set as i32), - record: Some(wa::SyncdRecord { - index: Some(wa::SyncdIndex { + operation: Some(wa::syncd_mutation::SyncdOperation::Set), + record: buffa::MessageField::some(wa::SyncdRecord { + index: buffa::MessageField::some(wa::SyncdIndex { blob: Some(index_mac), }), - value: Some(wa::SyncdValue { + value: buffa::MessageField::some(wa::SyncdValue { blob: Some(vec![0x5A; 48]), }), - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(b"AAAA".to_vec()), }), }), diff --git a/wacore/benches/history_sync_benchmark.rs b/wacore/benches/history_sync_benchmark.rs index 68665e1d1..cbfa16a54 100644 --- a/wacore/benches/history_sync_benchmark.rs +++ b/wacore/benches/history_sync_benchmark.rs @@ -3,9 +3,9 @@ //! decompressed). This is the heaviest single-shot pipeline in the library //! and the hottest consumer of the varint scan. +use buffa::Message; use divan::black_box; use flate2::{Compression, write::ZlibEncoder}; -use prost::Message; use std::io::Write; use waproto::whatsapp as wa; @@ -44,34 +44,36 @@ fn build_realistic_history_sync(n_convos: usize, msgs_per_convo: usize) -> Vec Vec wa::Message { wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("Benchmark message with a realistic amount of text content.".into()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { stanza_id: Some("3EB0F4E1D2C3B4A59687".into()), participant: Some("5511999990000@s.whatsapp.net".into()), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() } } @@ -63,7 +63,7 @@ fn bench_encode_and_pad(bencher: divan::Bencher) { fn bench_unpad_message_ref(bencher: divan::Bencher) { bencher .with_inputs(|| { - use prost::Message as _; + use buffa::Message as _; MessageUtils::pad_message_v2(text_message().encode_to_vec()) }) .bench_refs(|padded| { @@ -79,7 +79,7 @@ fn dm_shape(shape: &str) -> wa::Message { match shape { "text_reply" => text_message(), "media_refs" => wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage { + image_message: buffa::MessageField::some(wa::message::ImageMessage { url: Some("https://mmg.whatsapp.net/v/t62.7118-24/abc123".into()), direct_path: Some("/v/t62.7118-24/abc123".into()), mimetype: Some("image/jpeg".into()), @@ -92,7 +92,7 @@ fn dm_shape(shape: &str) -> wa::Message { width: Some(960), jpeg_thumbnail: Some(vec![0x7F; 6 * 1024]), ..Default::default() - })), + }), ..Default::default() }, "large_text" => wa::Message { @@ -108,10 +108,12 @@ fn recv_shape(shape: &str) -> wa::Message { // The first group message from a sender carries the SKDM inline // alongside the content. "group_skdm_text" => wa::Message { - sender_key_distribution_message: Some(wa::message::SenderKeyDistributionMessage { - group_id: Some("120363000000000001@g.us".into()), - axolotl_sender_key_distribution_message: Some(vec![0x33; 350]), - }), + sender_key_distribution_message: buffa::MessageField::some( + wa::message::SenderKeyDistributionMessage { + group_id: Some("120363000000000001@g.us".into()), + axolotl_sender_key_distribution_message: Some(vec![0x33; 350]), + }, + ), conversation: Some("Benchmark group message with realistic text.".into()), ..Default::default() }, @@ -126,7 +128,7 @@ fn recv_shape(shape: &str) -> wa::Message { fn bench_decode_plaintext(bencher: divan::Bencher, shape: &str) { bencher .with_inputs(|| { - use prost::Message as _; + use buffa::Message as _; MessageUtils::pad_message_v2(recv_shape(shape).encode_to_vec()) }) .bench_refs(|padded| { diff --git a/wacore/benches/reporting_token_benchmark.rs b/wacore/benches/reporting_token_benchmark.rs index b4a7fbbbd..8218fafb3 100644 --- a/wacore/benches/reporting_token_benchmark.rs +++ b/wacore/benches/reporting_token_benchmark.rs @@ -1,5 +1,5 @@ +use buffa::Message; use divan::black_box; -use prost::Message; use wacore::reporting_token::{ MESSAGE_SECRET_SIZE, REPORTING_TOKEN_KEY_SIZE, calculate_reporting_token, derive_reporting_token_key, generate_reporting_token, generate_reporting_token_content, @@ -20,15 +20,15 @@ fn create_simple_message() -> wa::Message { fn create_extended_message() -> wa::Message { wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("Test message with context info".to_string()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { is_forwarded: Some(true), forwarding_score: Some(5), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() } } diff --git a/wacore/benches/send_receive_benchmark.rs b/wacore/benches/send_receive_benchmark.rs index e8898e5ef..5ff56b59c 100644 --- a/wacore/benches/send_receive_benchmark.rs +++ b/wacore/benches/send_receive_benchmark.rs @@ -1,7 +1,7 @@ //! Full send/receive pipeline benchmarks using real `prepare_*_stanza` functions. use async_trait::async_trait; -use prost::Message as ProtoMessage; +use buffa::Message as ProtoMessage; use std::collections::HashMap; use std::hint::black_box; use wacore::client::context::{GroupInfo, SendContextResolver}; @@ -504,7 +504,7 @@ fn decrypt_dm( .unwrap(); let unpadded = MessageUtils::unpad_message_ref(&decrypted.plaintext, 2).unwrap(); - wa::Message::decode(unpadded).unwrap() + wa::Message::decode_from_slice(unpadded).unwrap() }) } @@ -521,7 +521,7 @@ fn decrypt_group( .unwrap(); let unpadded = MessageUtils::unpad_message_ref(&plaintext, 2).unwrap(); - wa::Message::decode(unpadded).unwrap() + wa::Message::decode_from_slice(unpadded).unwrap() }) } diff --git a/wacore/build.rs b/wacore/build.rs new file mode 100644 index 000000000..0b7794615 --- /dev/null +++ b/wacore/build.rs @@ -0,0 +1,79 @@ +//! Generates buffa types for the MLow runtime constant tables (the `voip` +//! feature's `src/voip/mlow/` codec) from the committed descriptor +//! `src/voip/mlow/tables.desc`, compiled once from `tables.proto`. Only the +//! `voip` feature consumes these tables, so codegen is skipped otherwise. +//! Reading the descriptor means consumers never need `protoc`; editing the +//! proto requires regenerating the descriptor via +//! `scripts/regenerate-tables-desc.sh`. + +fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed=src/voip/mlow/tables.desc"); + println!("cargo:rerun-if-changed=src/voip/mlow/tables.proto"); + println!("cargo:rerun-if-changed=src/voip/mlow/tables.desc.sha256"); + println!("cargo:rerun-if-changed=build.rs"); + + if std::env::var_os("CARGO_FEATURE_VOIP").is_none() { + return Ok(()); + } + + // Gated inside the voip block: the .sha256 only matters when the descriptor + // is actually consumed, so non-voip builds neither read nor validate it. + ensure_descriptor_fresh( + "src/voip/mlow/tables.proto", + "src/voip/mlow/tables.desc", + "src/voip/mlow/tables.desc.sha256", + "wacore (voip tables)", + "scripts/regenerate-tables-desc.sh", + )?; + + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR must be set by cargo"); + + buffa_build::Config::new() + .descriptor_set("src/voip/mlow/tables.desc") + .files(&["tables.proto"]) + .preserve_unknown_fields(false) + .out_dir(&out_dir) + .compile() +} + +/// Fail the build if the committed `.desc` no longer matches its `.proto` (proto +/// edited without rerunning the regenerate script), so codegen never silently +/// runs against a stale descriptor. +fn ensure_descriptor_fresh( + proto: &str, + desc: &str, + sha: &str, + label: &str, + regen: &str, +) -> std::io::Result<()> { + use sha2::{Digest as _, Sha256}; + + let hex = |bytes: &[u8]| -> String { + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() + }; + + let (mut want_proto, mut want_desc) = (None, None); + for line in std::fs::read_to_string(sha)?.lines() { + let mut parts = line.split_whitespace(); + match (parts.next(), parts.next()) { + (Some("proto"), Some(h)) => want_proto = Some(h.to_owned()), + (Some("desc"), Some(h)) => want_desc = Some(h.to_owned()), + _ => {} + } + } + + let got_proto = hex(&std::fs::read(proto)?); + let got_desc = hex(&std::fs::read(desc)?); + + if want_proto.as_deref() != Some(&got_proto) || want_desc.as_deref() != Some(&got_desc) { + return Err(std::io::Error::other(format!( + "{label}: {proto}/{desc} do not match {sha}. Run `{regen}` and commit all three. \ + expected proto {want_proto:?}, desc {want_desc:?}; got proto {got_proto}, desc {got_desc}" + ))); + } + + Ok(()) +} diff --git a/wacore/libsignal/Cargo.toml b/wacore/libsignal/Cargo.toml index d5519dd2b..3cf911523 100644 --- a/wacore/libsignal/Cargo.toml +++ b/wacore/libsignal/Cargo.toml @@ -12,6 +12,7 @@ aes = { workspace = true } arrayref = "0.3.9" async-lock = { workspace = true } async-trait = { workspace = true } +buffa = { workspace = true } bytes = { workspace = true } cbc = { workspace = true } chrono = { workspace = true, features = ["now"] } @@ -24,7 +25,6 @@ hex = { workspace = true } hkdf = { workspace = true } hmac = { workspace = true } log = { workspace = true } -prost = { workspace = true } rand = { workspace = true } serde = { workspace = true, features = ["alloc"] } sha1 = { workspace = true } diff --git a/wacore/libsignal/src/protocol/group_cipher.rs b/wacore/libsignal/src/protocol/group_cipher.rs index 13cae7ba2..243910e75 100644 --- a/wacore/libsignal/src/protocol/group_cipher.rs +++ b/wacore/libsignal/src/protocol/group_cipher.rs @@ -280,7 +280,7 @@ pub async fn process_sender_key_distribution_message( skdm.chain_key(), *skdm.signing_key(), None, - ); + )?; sender_key_store .store_sender_key(sender_key_name, sender_key_record) .await?; @@ -336,7 +336,7 @@ pub async fn create_sender_key_distribution_message( &sender_key, signing_key.public_key, Some(signing_key.private_key), - ); + )?; // Build SKDM before store so we can move ownership let skdm = build_skdm_from_record(&record)?; sender_key_store diff --git a/wacore/libsignal/src/protocol/identity_key.rs b/wacore/libsignal/src/protocol/identity_key.rs index 162454668..24e4ceef6 100644 --- a/wacore/libsignal/src/protocol/identity_key.rs +++ b/wacore/libsignal/src/protocol/identity_key.rs @@ -7,7 +7,7 @@ #![warn(missing_docs)] -use prost::Message; +use buffa::Message; use rand::{CryptoRng, Rng}; use crate::protocol::{ @@ -165,7 +165,7 @@ impl TryFrom<&[u8]> for IdentityKeyPair { type Error = SignalProtocolError; fn try_from(value: &[u8]) -> Result { - let structure = IdentityKeyPairStructure::decode(value) + let structure = IdentityKeyPairStructure::decode_from_slice(value) .map_err(|_| SignalProtocolError::InvalidProtobufEncoding)?; Ok(Self { identity_key: IdentityKey::try_from( diff --git a/wacore/libsignal/src/protocol/protocol.rs b/wacore/libsignal/src/protocol/protocol.rs index 34021a251..7181ca6e1 100644 --- a/wacore/libsignal/src/protocol/protocol.rs +++ b/wacore/libsignal/src/protocol/protocol.rs @@ -3,8 +3,9 @@ // SPDX-License-Identifier: AGPL-3.0-only // +use buffa::Message; +use buffa::view::MessageView; use hmac::{Hmac, KeyInit, Mac}; -use prost::Message; use rand::{CryptoRng, Rng}; use sha2::Sha256; use std::sync::OnceLock; @@ -119,11 +120,11 @@ impl SignalMessage { previous_counter: Some(previous_counter), ciphertext: Some(Vec::::from(ciphertext)), }; - let mut serialized = Vec::with_capacity(1 + message.encoded_len() + Self::MAC_LENGTH); + let mut size_cache = buffa::SizeCache::new(); + let message_len = message.compute_size(&mut size_cache) as usize; + let mut serialized = Vec::with_capacity(1 + message_len + Self::MAC_LENGTH); serialized.push(((message_version & 0xF) << 4) | CIPHERTEXT_MESSAGE_CURRENT_VERSION); - message - .encode(&mut serialized) - .expect("can always append to a buffer"); + message.write_to(&mut size_cache, &mut serialized); let mac = Self::compute_mac( sender_identity_key, receiver_identity_key, @@ -173,12 +174,11 @@ impl SignalMessage { fn decode_ciphertext(&self) -> Result> { let proto_bytes = &self.serialized[1..self.serialized.len() - Self::MAC_LENGTH]; - let proto = waproto::whatsapp::SignalMessage::decode(proto_bytes) + let view = waproto::whatsapp::SignalMessageView::decode_view(proto_bytes) .map_err(|_| SignalProtocolError::InvalidProtobufEncoding)?; - proto - .ciphertext + view.ciphertext .ok_or(SignalProtocolError::InvalidProtobufEncoding) - .map(|v| v.into_boxed_slice()) + .map(Box::from) } pub fn verify_mac( @@ -248,23 +248,23 @@ impl TryFrom<&[u8]> for SignalMessage { )); } - let proto_structure = waproto::whatsapp::SignalMessage::decode( + let view = waproto::whatsapp::SignalMessageView::decode_view( &value[1..value.len() - SignalMessage::MAC_LENGTH], ) .map_err(|_| SignalProtocolError::InvalidProtobufEncoding)?; - let sender_ratchet_key = proto_structure + let sender_ratchet_key = view .ratchet_key .ok_or(SignalProtocolError::InvalidProtobufEncoding)?; - let sender_ratchet_key = PublicKey::deserialize(&sender_ratchet_key)?; - let counter = proto_structure + let sender_ratchet_key = PublicKey::deserialize(sender_ratchet_key)?; + let counter = view .counter .ok_or(SignalProtocolError::InvalidProtobufEncoding)?; - let previous_counter = proto_structure.previous_counter.unwrap_or(0); - let ciphertext = proto_structure + let previous_counter = view.previous_counter.unwrap_or(0); + let ciphertext = view .ciphertext - .ok_or(SignalProtocolError::InvalidProtobufEncoding)? - .into_boxed_slice(); + .ok_or(SignalProtocolError::InvalidProtobufEncoding) + .map(Box::from)?; let ciphertext_cache = OnceLock::new(); let _ = ciphertext_cache.set(ciphertext); @@ -310,11 +310,11 @@ impl PreKeySignalMessage { identity_key: Some(identity_key.serialize().to_vec()), message: Some(Vec::from(message.as_ref())), }; - let mut serialized = Vec::with_capacity(1 + proto_message.encoded_len()); + let mut size_cache = buffa::SizeCache::new(); + let message_len = proto_message.compute_size(&mut size_cache) as usize; + let mut serialized = Vec::with_capacity(1 + message_len); serialized.push(((message_version & 0xF) << 4) | CIPHERTEXT_MESSAGE_CURRENT_VERSION); - proto_message - .encode(&mut serialized) - .expect("can always append to a Vec"); + proto_message.write_to(&mut size_cache, &mut serialized); Ok(Self { message_version, registration_id, @@ -395,32 +395,32 @@ impl TryFrom<&[u8]> for PreKeySignalMessage { )); } - let proto_structure = waproto::whatsapp::PreKeySignalMessage::decode(&value[1..]) + let view = waproto::whatsapp::PreKeySignalMessageView::decode_view(&value[1..]) .map_err(|_| SignalProtocolError::InvalidProtobufEncoding)?; - let base_key = proto_structure + let base_key = view .base_key .ok_or(SignalProtocolError::InvalidProtobufEncoding)?; - let identity_key = proto_structure + let identity_key = view .identity_key .ok_or(SignalProtocolError::InvalidProtobufEncoding)?; - let message = proto_structure + let message = view .message .ok_or(SignalProtocolError::InvalidProtobufEncoding)?; - let signed_pre_key_id = proto_structure + let signed_pre_key_id = view .signed_pre_key_id .ok_or(SignalProtocolError::InvalidProtobufEncoding)?; - let base_key = PublicKey::deserialize(base_key.as_ref())?; + let base_key = PublicKey::deserialize(base_key)?; Ok(PreKeySignalMessage { message_version, - registration_id: proto_structure.registration_id.unwrap_or(0), - pre_key_id: proto_structure.pre_key_id.map(|id| id.into()), + registration_id: view.registration_id.unwrap_or(0), + pre_key_id: view.pre_key_id.map(|id| id.into()), signed_pre_key_id: signed_pre_key_id.into(), base_key, - identity_key: IdentityKey::try_from(identity_key.as_ref())?, - message: SignalMessage::try_from(message.as_ref())?, + identity_key: IdentityKey::try_from(identity_key)?, + message: SignalMessage::try_from(message)?, serialized: Box::from(value), }) } @@ -473,12 +473,11 @@ impl SenderKeyMessage { // Build serialized buffer directly: [version_byte || proto || signature] // Sign over [version_byte || proto], then append signature let shifted_version = (message_version << 4) | 3u8; - let proto_len = proto_message.encoded_len(); + let mut size_cache = buffa::SizeCache::new(); + let proto_len = proto_message.compute_size(&mut size_cache) as usize; let mut serialized = Vec::with_capacity(1 + proto_len + Self::SIGNATURE_LEN); serialized.push(shifted_version); - proto_message - .encode(&mut serialized) - .expect("can always append to a buffer"); + proto_message.write_to(&mut size_cache, &mut serialized); // Sign the data we've built so far (version + proto) let signature = signature_key @@ -549,12 +548,11 @@ impl SenderKeyMessage { fn decode_ciphertext(&self) -> Result> { // serialized layout: [version_byte || protobuf || signature] let proto_bytes = &self.serialized[1..self.serialized.len() - Self::SIGNATURE_LEN]; - let proto = waproto::whatsapp::SenderKeyMessage::decode(proto_bytes) + let view = waproto::whatsapp::SenderKeyMessageView::decode_view(proto_bytes) .map_err(|_| SignalProtocolError::InvalidProtobufEncoding)?; - let ciphertext = proto - .ciphertext - .ok_or(SignalProtocolError::InvalidProtobufEncoding)?; - Ok(ciphertext.into_boxed_slice()) + view.ciphertext + .ok_or(SignalProtocolError::InvalidProtobufEncoding) + .map(Box::from) } #[inline] @@ -592,21 +590,21 @@ impl TryFrom<&[u8]> for SenderKeyMessage { message_version, )); } - let proto_structure = waproto::whatsapp::SenderKeyMessage::decode( + let view = waproto::whatsapp::SenderKeyMessageView::decode_view( &value[1..value.len() - Self::SIGNATURE_LEN], ) .map_err(|_| SignalProtocolError::InvalidProtobufEncoding)?; - let chain_id = proto_structure + let chain_id = view .id .ok_or(SignalProtocolError::InvalidProtobufEncoding)?; - let iteration = proto_structure + let iteration = view .iteration .ok_or(SignalProtocolError::InvalidProtobufEncoding)?; - let ciphertext = proto_structure + let ciphertext = view .ciphertext - .ok_or(SignalProtocolError::InvalidProtobufEncoding)? - .into_boxed_slice(); + .ok_or(SignalProtocolError::InvalidProtobufEncoding) + .map(Box::from)?; let ciphertext_cache = OnceLock::new(); let _ = ciphertext_cache.set(ciphertext); @@ -645,11 +643,11 @@ impl SenderKeyDistributionMessage { chain_key: Some(chain_key.to_vec()), signing_key: Some(signing_key.serialize().to_vec()), }; - let mut serialized = Vec::with_capacity(1 + proto_message.encoded_len()); + let mut size_cache = buffa::SizeCache::new(); + let message_len = proto_message.compute_size(&mut size_cache) as usize; + let mut serialized = Vec::with_capacity(1 + message_len); serialized.push(((message_version & 0xF) << 4) | SENDERKEY_MESSAGE_CURRENT_VERSION); - proto_message - .encode(&mut serialized) - .expect("can always append to a buffer"); + proto_message.write_to(&mut size_cache, &mut serialized); Ok(Self { message_version, @@ -725,30 +723,30 @@ impl TryFrom<&[u8]> for SenderKeyDistributionMessage { )); } - let proto_structure = waproto::whatsapp::SenderKeyDistributionMessage::decode(&value[1..]) + let view = waproto::whatsapp::SenderKeyDistributionMessageView::decode_view(&value[1..]) .map_err(|_| SignalProtocolError::InvalidProtobufEncoding)?; - let chain_id = proto_structure + let chain_id = view .id .ok_or(SignalProtocolError::InvalidProtobufEncoding)?; - let iteration = proto_structure + let iteration = view .iteration .ok_or(SignalProtocolError::InvalidProtobufEncoding)?; - let chain_key_vec = proto_structure + let chain_key_bytes = view .chain_key .ok_or(SignalProtocolError::InvalidProtobufEncoding)?; - let signing_key = proto_structure + let signing_key = view .signing_key .ok_or(SignalProtocolError::InvalidProtobufEncoding)?; - if chain_key_vec.len() != 32 || signing_key.len() != 33 { + if chain_key_bytes.len() != 32 || signing_key.len() != 33 { return Err(SignalProtocolError::InvalidProtobufEncoding); } - let chain_key: [u8; 32] = chain_key_vec + let chain_key: [u8; 32] = chain_key_bytes .try_into() .map_err(|_| SignalProtocolError::InvalidProtobufEncoding)?; - let signing_key = PublicKey::deserialize(&signing_key)?; + let signing_key = PublicKey::deserialize(signing_key)?; Ok(SenderKeyDistributionMessage { message_version, @@ -784,15 +782,85 @@ impl PlaintextContent { } } -#[derive(Clone, PartialEq, ::prost::Message)] +#[derive(Clone, PartialEq, Default)] pub struct DecryptionErrorMessageProto { - /// set to the public ratchet key from the SignalMessage if a 1-1 payload fails to decrypt - #[prost(bytes = "vec", optional, tag = "1")] - pub ratchet_key: ::core::option::Option<::prost::alloc::vec::Vec>, - #[prost(uint64, optional, tag = "2")] - pub timestamp: ::core::option::Option, - #[prost(uint32, optional, tag = "3")] - pub device_id: ::core::option::Option, + pub ratchet_key: Option>, + pub timestamp: Option, + pub device_id: Option, +} + +impl buffa::DefaultInstance for DecryptionErrorMessageProto { + fn default_instance() -> &'static Self { + static VALUE: buffa::__private::OnceBox = + buffa::__private::OnceBox::new(); + VALUE.get_or_init(|| Box::new(DecryptionErrorMessageProto::default())) + } +} + +impl buffa::Message for DecryptionErrorMessageProto { + fn compute_size(&self, _cache: &mut buffa::SizeCache) -> u32 { + let mut size = 0u32; + if let Some(ref v) = self.ratchet_key { + size += 1 + buffa::types::bytes_encoded_len(v) as u32; + } + if let Some(v) = self.timestamp { + size += 1 + buffa::types::uint64_encoded_len(v) as u32; + } + if let Some(v) = self.device_id { + size += 1 + buffa::types::uint32_encoded_len(v) as u32; + } + size + } + + fn write_to(&self, _cache: &mut buffa::SizeCache, buf: &mut impl buffa::bytes::BufMut) { + if let Some(ref v) = self.ratchet_key { + buffa::encoding::Tag::new(1, buffa::encoding::WireType::LengthDelimited).encode(buf); + buffa::types::encode_bytes(v, buf); + } + if let Some(v) = self.timestamp { + buffa::encoding::Tag::new(2, buffa::encoding::WireType::Varint).encode(buf); + buffa::types::encode_uint64(v, buf); + } + if let Some(v) = self.device_id { + buffa::encoding::Tag::new(3, buffa::encoding::WireType::Varint).encode(buf); + buffa::types::encode_uint32(v, buf); + } + } + + fn merge_field( + &mut self, + tag: buffa::encoding::Tag, + buf: &mut impl buffa::bytes::Buf, + ctx: buffa::DecodeContext<'_>, + ) -> core::result::Result<(), buffa::DecodeError> { + use buffa::encoding::WireType; + // Validate wire type per field; a mismatch falls through to skip_field + // instead of mis-decoding peer input. + match tag.field_number() { + 1 if tag.wire_type() == WireType::LengthDelimited => { + buffa::types::merge_bytes(self.ratchet_key.get_or_insert_with(Vec::new), buf)?; + } + 2 if tag.wire_type() == WireType::Varint => { + self.timestamp = Some(buffa::types::decode_uint64(buf)?); + } + 3 if tag.wire_type() == WireType::Varint => { + self.device_id = Some(buffa::types::decode_uint32(buf)?); + } + _ => { + // Thread the live recursion budget through: a bare skip_field + // restarts it at RECURSION_LIMIT, which unknown group fields + // could exploit for depth-doubling. + buffa::encoding::skip_field_depth(tag, buf, ctx.depth())?; + } + } + Ok(()) + } + + fn clear(&mut self) { + self.ratchet_key = None; + self.timestamp = None; + self.device_id = None; + } } impl TryFrom<&[u8]> for PlaintextContent { @@ -885,7 +953,7 @@ impl TryFrom<&[u8]> for DecryptionErrorMessage { type Error = SignalProtocolError; fn try_from(value: &[u8]) -> Result { - let proto_structure = DecryptionErrorMessageProto::decode(value) + let proto_structure = DecryptionErrorMessageProto::decode_from_slice(value) .map_err(|_| SignalProtocolError::InvalidProtobufEncoding)?; let timestamp = proto_structure .timestamp @@ -904,3 +972,27 @@ impl TryFrom<&[u8]> for DecryptionErrorMessage { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decryption_error_proto_uses_buffa_size_cache_encoding() { + let proto = DecryptionErrorMessageProto { + ratchet_key: Some(vec![1, 2, 3]), + timestamp: Some(150), + device_id: Some(7), + }; + + let bytes = proto.encode_to_vec(); + assert_eq!(bytes, [0x0a, 3, 1, 2, 3, 0x10, 0x96, 0x01, 0x18, 7]); + assert_eq!(proto.encoded_len() as usize, bytes.len()); + + let decoded = + DecryptionErrorMessageProto::decode_from_slice(&bytes).expect("decode test proto"); + assert_eq!(decoded.ratchet_key.as_deref(), Some(&[1, 2, 3][..])); + assert_eq!(decoded.timestamp, Some(150)); + assert_eq!(decoded.device_id, Some(7)); + } +} diff --git a/wacore/libsignal/src/protocol/ratchet/keys.rs b/wacore/libsignal/src/protocol/ratchet/keys.rs index fd0bd369c..86117fb7f 100644 --- a/wacore/libsignal/src/protocol/ratchet/keys.rs +++ b/wacore/libsignal/src/protocol/ratchet/keys.rs @@ -79,7 +79,7 @@ impl MessageKeyGenerator { Self::Serialized(pb) => pb, // Need to serialize: derive keys and convert Self::Seed(_) | Self::Keys(_) => { - use prost::bytes::Bytes; + use bytes::Bytes; let keys = self.generate_keys(); session_structure::chain::MessageKey { cipher_key: Some(Bytes::copy_from_slice(keys.cipher_key())), diff --git a/wacore/libsignal/src/protocol/sender_keys.rs b/wacore/libsignal/src/protocol/sender_keys.rs index 851d14b7d..2b33c224e 100644 --- a/wacore/libsignal/src/protocol/sender_keys.rs +++ b/wacore/libsignal/src/protocol/sender_keys.rs @@ -5,7 +5,7 @@ use std::collections::VecDeque; -use prost::Message; +use buffa::{Message, MessageField}; use hmac::{HmacReset, KeyInit, Mac}; use sha2::Sha256; @@ -51,11 +51,12 @@ impl SenderMessageKey { } pub(crate) fn from_protobuf(smk: sender_key_state_structure::SenderMessageKey) -> Self { - let seed_bytes = smk.seed.unwrap_or_default(); - let seed: [u8; 32] = seed_bytes - .as_ref() - .try_into() - .expect("SenderMessageKey seed must be exactly 32 bytes"); + // Seed is validated at deserialization time; fall back to zeroes on corrupt in-memory data. + let seed: [u8; 32] = smk + .seed + .as_deref() + .and_then(|b| b.try_into().ok()) + .unwrap_or_default(); Self::new(smk.iteration.unwrap_or_default(), seed) } @@ -72,7 +73,7 @@ impl SenderMessageKey { } pub(crate) fn as_protobuf(&self) -> sender_key_state_structure::SenderMessageKey { - use prost::bytes::Bytes; + use bytes::Bytes; sender_key_state_structure::SenderMessageKey { iteration: Some(self.iteration), seed: Some(Bytes::copy_from_slice(&self.seed)), @@ -80,6 +81,13 @@ impl SenderMessageKey { } } +fn seed_to_array(seed: Option<&bytes::Bytes>) -> Result<[u8; 32], SignalProtocolError> { + seed.ok_or(SignalProtocolError::InvalidProtobufEncoding)? + .as_ref() + .try_into() + .map_err(|_| SignalProtocolError::InvalidProtobufEncoding) +} + #[derive(Debug, Clone, Copy)] pub struct SenderChainKey { iteration: u32, @@ -158,7 +166,7 @@ impl SenderChainKey { } pub(crate) fn as_protobuf(&self) -> sender_key_state_structure::SenderChainKey { - use prost::bytes::Bytes; + use bytes::Bytes; sender_key_state_structure::SenderChainKey { iteration: Some(self.iteration), seed: Some(Bytes::copy_from_slice(&self.chain_key)), @@ -218,13 +226,17 @@ impl SenderKeyState { chain_key: &[u8], signature_key: PublicKey, signature_private_key: Option, - ) -> SenderKeyState { - use prost::bytes::Bytes; - let chain_key_arr: [u8; 32] = chain_key.try_into().expect("chain_key must be 32 bytes"); + ) -> Result { + use bytes::Bytes; + let chain_key_arr: [u8; 32] = chain_key + .try_into() + .map_err(|_| SignalProtocolError::InvalidProtobufEncoding)?; let state = SenderKeyStateStructure { sender_key_id: Some(chain_id), - sender_chain_key: Some(SenderChainKey::new(iteration, chain_key_arr).as_protobuf()), - sender_signing_key: Some(sender_key_state_structure::SenderSigningKey { + sender_chain_key: MessageField::some( + SenderChainKey::new(iteration, chain_key_arr).as_protobuf(), + ), + sender_signing_key: MessageField::some(sender_key_state_structure::SenderSigningKey { public: Some(Bytes::copy_from_slice(&signature_key.serialize())), private: signature_private_key .as_ref() @@ -240,22 +252,19 @@ impl SenderKeyState { } let verifying_key_memo = std::sync::OnceLock::new(); if signing_key_memo.get().is_none() { - // Receive-side state (no private key): this key will verify every - // incoming message, so build the verifier and derive its Edwards - // entries here, at SKDM processing, once per sender rotation. - // Send-side states never verify their own messages, so they skip - // even the verifier allocation; the memo builds lazily if ever - // asked. + // Receive-side state (no private key): build the verifier and derive its + // Edwards entries here, at SKDM processing, once per sender rotation. + // Send-side states skip the allocation; it builds lazily if ever asked. let verifier = crate::core::curve::PreparedVerifyingKey::new(&signature_key); verifier.precompute(); let _ = verifying_key_memo.set(verifier); } - Self { + Ok(Self { state, message_keys: std::sync::Arc::new(Vec::new()), signing_key_memo, verifying_key_memo, - } + }) } pub(crate) fn from_protobuf(mut state: SenderKeyStateStructure) -> Self { @@ -279,7 +288,7 @@ impl SenderKeyState { } pub fn sender_chain_key(&self) -> Option { - let sender_chain = self.state.sender_chain_key.as_ref()?; + let sender_chain = self.state.sender_chain_key.as_option()?; let seed: [u8; 32] = sender_chain .seed .as_deref() @@ -293,11 +302,11 @@ impl SenderKeyState { } pub fn set_sender_chain_key(&mut self, chain_key: SenderChainKey) { - self.state.sender_chain_key = Some(chain_key.as_protobuf()); + self.state.sender_chain_key = MessageField::some(chain_key.as_protobuf()); } pub fn signing_key_public(&self) -> Result { - if let Some(ref signing_key) = self.state.sender_signing_key { + if let Some(signing_key) = self.state.sender_signing_key.as_option() { let public = signing_key .public .as_ref() @@ -330,7 +339,7 @@ impl SenderKeyState { if let Some(key) = self.signing_key_memo.get() { return Ok(key.clone()); } - if let Some(ref signing_key) = self.state.sender_signing_key { + if let Some(signing_key) = self.state.sender_signing_key.as_option() { let private = signing_key .private .as_ref() @@ -405,12 +414,19 @@ impl SenderKeyRecord { } pub fn deserialize(buf: &[u8]) -> Result { - let skr = SenderKeyRecordStructure::decode(buf) + let skr = SenderKeyRecordStructure::decode_from_slice(buf) .map_err(|_| SignalProtocolError::InvalidProtobufEncoding)?; let mut states = VecDeque::with_capacity(skr.sender_key_states.len()); for state in skr.sender_key_states { - states.push_back(SenderKeyState::from_protobuf(state)) + // Validate seeds eagerly so callers get a clear error on corrupt data. + if let Some(sender_chain) = state.sender_chain_key.as_option() { + let _ = seed_to_array(sender_chain.seed.as_ref())?; + } + for smk in &state.sender_message_keys { + let _ = seed_to_array(smk.seed.as_ref())?; + } + states.push_back(SenderKeyState::from_protobuf(state)); } Ok(Self { states }) } @@ -455,7 +471,7 @@ impl SenderKeyRecord { chain_key: &[u8], signature_key: PublicKey, signature_private_key: Option, - ) { + ) -> Result<(), SignalProtocolError> { let existing_state = self.remove_state(chain_id, signature_key); if self.remove_states_with_chain_id(chain_id) > 0 { @@ -472,7 +488,7 @@ impl SenderKeyRecord { chain_key, signature_key, signature_private_key, - ), + )?, Some(state) => state, }; @@ -481,6 +497,7 @@ impl SenderKeyRecord { } self.states.push_front(state); + Ok(()) } /// Remove the state with the matching `chain_id` and `signature_key`. @@ -619,7 +636,8 @@ mod tests { let keypair = KeyPair::generate(&mut rng); let chain_key = [0x42u8; 32]; - let state = SenderKeyState::new(3, 12345, 0, &chain_key, keypair.public_key, None); + let state = SenderKeyState::new(3, 12345, 0, &chain_key, keypair.public_key, None) + .expect("sender key state should be valid"); assert_eq!(state.chain_id(), 12345); assert_eq!(state.message_version(), 3); @@ -629,6 +647,17 @@ mod tests { assert!(state.signing_key_private().is_err()); } + #[test] + fn test_sender_key_state_rejects_invalid_chain_key_length() { + let mut rng = rand::make_rng::(); + let keypair = KeyPair::generate(&mut rng); + + let err = SenderKeyState::new(3, 12345, 0, &[0x42u8; 31], keypair.public_key, None) + .expect_err("invalid chain key length should fail"); + + assert!(matches!(err, SignalProtocolError::InvalidProtobufEncoding)); + } + /// Test SenderKeyState with private signing key #[test] fn test_sender_key_state_with_private_key() { @@ -643,7 +672,8 @@ mod tests { &chain_key, keypair.public_key, Some(keypair.private_key), - ); + ) + .expect("sender key state should be valid"); assert!(state.signing_key_public().is_ok()); assert!(state.signing_key_private().is_ok()); @@ -661,7 +691,8 @@ mod tests { &chain_key, signing.public_key, Some(signing.private_key), - ); + ) + .expect("valid inputs"); // new() received the parsed key: memo pre-populated and pre-warmed. assert!(state.signing_key_memo_initialized()); @@ -725,7 +756,8 @@ mod tests { &chain_key, keypair.public_key, Some(keypair.private_key), - ); + ) + .expect("sender key state should be valid"); let initial_sck = state .sender_chain_key() @@ -756,7 +788,8 @@ mod tests { &chain_key, keypair.public_key, Some(keypair.private_key), - ); + ) + .expect("sender key state should be valid"); let smk = SenderMessageKey::new(5, [0xAA; 32]); state.add_sender_message_key(&smk); @@ -785,7 +818,8 @@ mod tests { &chain_key, keypair.public_key, Some(keypair.private_key), - ); + ) + .expect("sender key state should be valid"); // Amortized eviction uses MESSAGE_KEY_PRUNE_THRESHOLD. // Eviction triggers when len > MAX_MESSAGE_KEYS + MESSAGE_KEY_PRUNE_THRESHOLD. @@ -821,14 +855,16 @@ mod tests { let chain_key = [0x42u8; 32]; let mut record = SenderKeyRecord::new_empty(); - record.add_sender_key_state( - 3, - 12345, - 0, - &chain_key, - keypair.public_key, - Some(keypair.private_key), - ); + record + .add_sender_key_state( + 3, + 12345, + 0, + &chain_key, + keypair.public_key, + Some(keypair.private_key), + ) + .expect("add_sender_key_state should succeed"); { let state = record.sender_key_state_mut().expect("state exists"); @@ -862,7 +898,8 @@ mod tests { let keypair = KeyPair::generate(&mut rng); let chain_key = [0x42u8; 32]; - let mut original = SenderKeyState::new(3, 1, 0, &chain_key, keypair.public_key, None); + let mut original = + SenderKeyState::new(3, 1, 0, &chain_key, keypair.public_key, None).expect("valid"); original.add_sender_message_key(&SenderMessageKey::new(7, [7u8; 32])); // Clone shares the backlog Arc (mirrors the cache keeping its copy while @@ -896,14 +933,16 @@ mod tests { let chain_key = [0x42u8; 32]; let mut record = SenderKeyRecord::new_empty(); - record.add_sender_key_state( - 3, - 12345, - 0, - &chain_key, - keypair.public_key, - Some(keypair.private_key), - ); + record + .add_sender_key_state( + 3, + 12345, + 0, + &chain_key, + keypair.public_key, + Some(keypair.private_key), + ) + .expect("sender key state should be valid"); let state = record .sender_key_state() @@ -922,14 +961,16 @@ mod tests { // Add more than MAX_SENDER_KEY_STATES for i in 0..(consts::MAX_SENDER_KEY_STATES + 5) { let keypair = KeyPair::generate(&mut rng); - record.add_sender_key_state( - 3, - i as u32, - 0, - &chain_key, - keypair.public_key, - Some(keypair.private_key), - ); + record + .add_sender_key_state( + 3, + i as u32, + 0, + &chain_key, + keypair.public_key, + Some(keypair.private_key), + ) + .expect("sender key state should be valid"); } // Should not have more than MAX_SENDER_KEY_STATES @@ -946,22 +987,26 @@ mod tests { let chain_key = [0x42u8; 32]; let mut record = SenderKeyRecord::new_empty(); - record.add_sender_key_state( - 3, - 111, - 0, - &chain_key, - keypair1.public_key, - Some(keypair1.private_key), - ); - record.add_sender_key_state( - 3, - 222, - 0, - &chain_key, - keypair2.public_key, - Some(keypair2.private_key), - ); + record + .add_sender_key_state( + 3, + 111, + 0, + &chain_key, + keypair1.public_key, + Some(keypair1.private_key), + ) + .expect("sender key state should be valid"); + record + .add_sender_key_state( + 3, + 222, + 0, + &chain_key, + keypair2.public_key, + Some(keypair2.private_key), + ) + .expect("sender key state should be valid"); // Should find chain 222 (most recent is at front) let state = record.sender_key_state_for_chain_id(222); @@ -986,14 +1031,16 @@ mod tests { let chain_key = [0x42u8; 32]; let mut record = SenderKeyRecord::new_empty(); - record.add_sender_key_state( - 3, - 12345, - 5, - &chain_key, - keypair.public_key, - Some(keypair.private_key), - ); + record + .add_sender_key_state( + 3, + 12345, + 5, + &chain_key, + keypair.public_key, + Some(keypair.private_key), + ) + .expect("sender key state should be valid"); let serialized = record.serialize().expect("serialization should succeed"); let deserialized = @@ -1006,6 +1053,48 @@ mod tests { assert!(state.sender_chain_key().is_some()); } + #[test] + fn test_sender_key_record_deserialize_rejects_invalid_chain_seed() { + let record = SenderKeyRecordStructure { + sender_key_states: vec![SenderKeyStateStructure { + sender_key_id: Some(12345), + sender_chain_key: MessageField::some(sender_key_state_structure::SenderChainKey { + iteration: Some(0), + seed: Some(bytes::Bytes::copy_from_slice(&[0x42; 31])), + }), + ..Default::default() + }], + }; + + let err = SenderKeyRecord::deserialize(&record.encode_to_vec()) + .expect_err("invalid sender chain seed should fail"); + + assert!(matches!(err, SignalProtocolError::InvalidProtobufEncoding)); + } + + #[test] + fn test_sender_key_record_deserialize_rejects_invalid_message_seed() { + let record = SenderKeyRecordStructure { + sender_key_states: vec![SenderKeyStateStructure { + sender_key_id: Some(12345), + sender_chain_key: MessageField::some(sender_key_state_structure::SenderChainKey { + iteration: Some(0), + seed: Some(bytes::Bytes::copy_from_slice(&[0x42; 32])), + }), + sender_message_keys: vec![sender_key_state_structure::SenderMessageKey { + iteration: Some(1), + seed: Some(bytes::Bytes::copy_from_slice(&[0x43; 31])), + }], + ..Default::default() + }], + }; + + let err = SenderKeyRecord::deserialize(&record.encode_to_vec()) + .expect_err("invalid sender message seed should fail"); + + assert!(matches!(err, SignalProtocolError::InvalidProtobufEncoding)); + } + /// Test that step_with_message_key produces the same results as /// calling sender_message_key() and next() separately #[test] diff --git a/wacore/libsignal/src/protocol/session_cipher.rs b/wacore/libsignal/src/protocol/session_cipher.rs index f7940c038..e50caabb3 100644 --- a/wacore/libsignal/src/protocol/session_cipher.rs +++ b/wacore/libsignal/src/protocol/session_cipher.rs @@ -229,7 +229,7 @@ async fn message_encrypt_inner( .save_identity(remote_address, &their_identity_key) .await?; - session_state.set_sender_chain_key(&next_chain_key); + session_state.set_sender_chain_key(&next_chain_key)?; Ok(message) } diff --git a/wacore/libsignal/src/protocol/state/prekey.rs b/wacore/libsignal/src/protocol/state/prekey.rs index 31f5f5860..da07f803b 100644 --- a/wacore/libsignal/src/protocol/state/prekey.rs +++ b/wacore/libsignal/src/protocol/state/prekey.rs @@ -5,7 +5,7 @@ use std::fmt; -use prost::Message; +use buffa::Message; use crate::protocol::{ KeyPair, PrivateKey, PublicKey, Result, SignalProtocolError, stores::PreKeyRecordStructure, @@ -43,7 +43,7 @@ impl PreKeyRecord { pub fn deserialize(data: &[u8]) -> Result { Ok(Self { - pre_key: PreKeyRecordStructure::decode(data) + pre_key: PreKeyRecordStructure::decode_from_slice(data) .map_err(|_| SignalProtocolError::InvalidProtobufEncoding)?, }) } diff --git a/wacore/libsignal/src/protocol/state/session.rs b/wacore/libsignal/src/protocol/state/session.rs index 97a12c21f..e104d15ff 100644 --- a/wacore/libsignal/src/protocol/state/session.rs +++ b/wacore/libsignal/src/protocol/state/session.rs @@ -6,15 +6,16 @@ use std::result::Result; use std::sync::Arc; -use prost::Message; +use buffa::view::MessageView; +use buffa::{Message, MessageField}; use subtle::ConstantTimeEq; use crate::core::curve::KeyType; use crate::protocol::ratchet::keys::MessageKeyGenerator; use crate::protocol::ratchet::{ChainKey, RootKey}; use crate::protocol::state::{PreKeyId, SignedPreKeyId}; +use crate::protocol::stores::SessionStructure; use crate::protocol::stores::session_structure::{self}; -use crate::protocol::stores::{RecordStructure, SessionStructure}; use crate::protocol::{IdentityKey, KeyPair, PrivateKey, PublicKey, SignalProtocolError, consts}; /// A distinct error type to keep from accidentally propagating deserialization errors. @@ -80,8 +81,10 @@ pub struct SessionState { /// Held opaque; restore via `SessionState::restore_decrypt_snapshot`. pub struct DecryptSnapshot { receiver_chains: Vec, - root_key: Option<::prost::alloc::vec::Vec>, + root_key: Option>, previous_counter: Option, + // Stored as `Option` rather than `MessageField` so the snapshot doesn't + // bind to buffa's sub-message representation (Box vs inline). sender_chain: Option, } @@ -99,7 +102,7 @@ impl SessionState { receiver_chains: self.session.receiver_chains.clone(), root_key: self.session.root_key.clone(), previous_counter: self.session.previous_counter, - sender_chain: self.session.sender_chain.clone(), + sender_chain: self.session.sender_chain.as_option().cloned(), } } @@ -111,7 +114,7 @@ impl SessionState { self.session.receiver_chains = snap.receiver_chains; self.session.root_key = snap.root_key; self.session.previous_counter = snap.previous_counter; - self.session.sender_chain = snap.sender_chain; + self.session.sender_chain = snap.sender_chain.into(); } pub fn new( @@ -128,14 +131,11 @@ impl SessionState { remote_identity_public: Some(their_identity.serialize().to_vec()), root_key: Some(root_key.key().to_vec()), previous_counter: Some(0), - sender_chain: None, receiver_chains: vec![], - pending_pre_key: None, remote_registration_id: Some(0), local_registration_id: Some(0), alice_base_key: Some(alice_base_key.serialize().to_vec()), - needs_refresh: None, - pending_key_exchange: None, + ..Default::default() }, } } @@ -209,17 +209,17 @@ impl SessionState { } pub fn sender_ratchet_key(&self) -> Result { - match self.session.sender_chain { - None => Err(InvalidSessionError("missing sender chain")), - Some(ref c) => { - let key_bytes = c - .sender_ratchet_key - .as_ref() - .ok_or(InvalidSessionError("missing sender ratchet key"))?; - PublicKey::deserialize(key_bytes) - .map_err(|_| InvalidSessionError("invalid sender chain ratchet key")) - } - } + let c = self + .session + .sender_chain + .as_option() + .ok_or(InvalidSessionError("missing sender chain"))?; + let key_bytes = c + .sender_ratchet_key + .as_ref() + .ok_or(InvalidSessionError("missing sender ratchet key"))?; + PublicKey::deserialize(key_bytes) + .map_err(|_| InvalidSessionError("invalid sender chain ratchet key")) } pub fn sender_ratchet_key_for_logging(&self) -> Result { @@ -227,21 +227,21 @@ impl SessionState { } pub fn sender_ratchet_private_key(&self) -> Result { - match self.session.sender_chain { - None => Err(InvalidSessionError("missing sender chain")), - Some(ref c) => { - let key_bytes = c - .sender_ratchet_key_private - .as_ref() - .ok_or(InvalidSessionError("missing sender ratchet private key"))?; - PrivateKey::deserialize(key_bytes) - .map_err(|_| InvalidSessionError("invalid sender chain private ratchet key")) - } - } + let c = self + .session + .sender_chain + .as_option() + .ok_or(InvalidSessionError("missing sender chain"))?; + let key_bytes = c + .sender_ratchet_key_private + .as_ref() + .ok_or(InvalidSessionError("missing sender ratchet private key"))?; + PrivateKey::deserialize(key_bytes) + .map_err(|_| InvalidSessionError("invalid sender chain private ratchet key")) } pub fn has_usable_sender_chain(&self) -> Result { - if self.session.sender_chain.is_none() { + if self.session.sender_chain.is_unset() { return Ok(false); } // We removed timestamp from PendingPreKey, so we can't check for expiration here. @@ -256,7 +256,7 @@ impl SessionState { let chain_key_idx = chain .chain_key - .as_ref() + .as_option() .and_then(|chain_key| chain_key.index); results.push((sender_ratchet_public, chain_key_idx)) @@ -314,7 +314,7 @@ impl SessionState { let chain = &self.session.receiver_chains[idx]; let chain_key = chain .chain_key - .as_ref() + .as_option() .ok_or(InvalidSessionError("missing receiver chain key"))?; let key_bytes = chain_key .key @@ -330,7 +330,7 @@ impl SessionState { } pub fn add_receiver_chain(&mut self, sender: &PublicKey, chain_key: &ChainKey) { - use prost::bytes::Bytes; + use bytes::Bytes; let chain_key = session_structure::chain::ChainKey { index: Some(chain_key.index()), key: Some(Bytes::copy_from_slice(chain_key.key())), @@ -339,7 +339,7 @@ impl SessionState { let chain = session_structure::Chain { sender_ratchet_key: Some(sender.serialize().to_vec()), sender_ratchet_key_private: Some(vec![]), - chain_key: Some(chain_key), + chain_key: MessageField::some(chain_key), message_keys: vec![], }; @@ -366,7 +366,7 @@ impl SessionState { } pub fn set_sender_chain(&mut self, sender: &KeyPair, next_chain_key: &ChainKey) { - use prost::bytes::Bytes; + use bytes::Bytes; let chain_key = session_structure::chain::ChainKey { index: Some(next_chain_key.index()), key: Some(Bytes::copy_from_slice(next_chain_key.key())), @@ -375,11 +375,11 @@ impl SessionState { let new_chain = session_structure::Chain { sender_ratchet_key: Some(sender.public_key.serialize().to_vec()), sender_ratchet_key_private: Some(sender.private_key.serialize().to_vec()), - chain_key: Some(chain_key), + chain_key: MessageField::some(chain_key), message_keys: vec![], }; - self.session.sender_chain = Some(new_chain); + self.session.sender_chain = MessageField::some(new_chain); } pub fn with_sender_chain(mut self, sender: &KeyPair, next_chain_key: &ChainKey) -> Self { @@ -391,12 +391,12 @@ impl SessionState { let sender_chain = self .session .sender_chain - .as_ref() + .as_option() .ok_or(InvalidSessionError("missing sender chain"))?; let chain_key = sender_chain .chain_key - .as_ref() + .as_option() .ok_or(InvalidSessionError("missing sender chain key"))?; let key_bytes = chain_key @@ -417,29 +417,25 @@ impl SessionState { Ok(self.get_sender_chain_key()?.key().to_vec()) } - pub fn set_sender_chain_key(&mut self, next_chain_key: &ChainKey) { - use prost::bytes::Bytes; + pub fn set_sender_chain_key( + &mut self, + next_chain_key: &ChainKey, + ) -> Result<(), InvalidSessionError> { + use bytes::Bytes; let chain_key = session_structure::chain::ChainKey { index: Some(next_chain_key.index()), key: Some(Bytes::copy_from_slice(next_chain_key.key())), }; - // Is it actually valid to call this function with sender_chain == None? - - let new_chain = match self.session.sender_chain.take() { - None => session_structure::Chain { - sender_ratchet_key: Some(vec![]), - sender_ratchet_key_private: Some(vec![]), - chain_key: Some(chain_key), - message_keys: vec![], - }, - Some(mut c) => { - c.chain_key = Some(chain_key); - c - } - }; + let mut new_chain = self + .session + .sender_chain + .take() + .ok_or(InvalidSessionError("missing sender chain"))?; + new_chain.chain_key = MessageField::some(chain_key); - self.session.sender_chain = Some(new_chain); + self.session.sender_chain = MessageField::some(new_chain); + Ok(()) } pub fn get_message_keys( @@ -531,9 +527,9 @@ impl SessionState { .get_receiver_chain_index(sender)? .expect("called set_receiver_chain_key for a non-existent chain"); - use prost::bytes::Bytes; + use bytes::Bytes; self.session.receiver_chains[chain_idx].chain_key = - Some(session_structure::chain::ChainKey { + MessageField::some(session_structure::chain::ChainKey { index: Some(chain_key.index()), key: Some(Bytes::copy_from_slice(chain_key.key())), }); @@ -553,13 +549,13 @@ impl SessionState { signed_pre_key_id: Some(signed_ec_pre_key_id as i32), base_key: Some(base_key.serialize().to_vec()), }; - self.session.pending_pre_key = Some(pending); + self.session.pending_pre_key = MessageField::some(pending); } pub fn unacknowledged_pre_key_message_items( &self, ) -> Result, InvalidSessionError> { - if let Some(ref pending_pre_key) = self.session.pending_pre_key { + if let Some(pending_pre_key) = self.session.pending_pre_key.as_option() { Ok(Some(UnacknowledgedPreKeyMessageItems::new( pending_pre_key.pre_key_id.map(Into::into), (pending_pre_key.signed_pre_key_id.unwrap_or(0) as u32).into(), @@ -595,7 +591,7 @@ impl SessionState { pending_key_exchange: _pending_key_exchange, } = &self.session; - self.session.pending_pre_key = None; + self.session.pending_pre_key = MessageField::none(); } pub fn set_remote_registration_id(&mut self, registration_id: u32) { @@ -655,23 +651,32 @@ impl SessionRecord { } pub fn deserialize(bytes: &[u8]) -> Result { - let mut record = RecordStructure::decode(bytes) + use waproto::whatsapp::RecordStructureView; + + // Decode to a zero-copy view first, then only convert sessions we + // actually keep to owned. Excess previous_sessions beyond + // ARCHIVED_STATES_MAX_LENGTH are never fully allocated. + let view = RecordStructureView::decode_view(bytes) .map_err(|_| InvalidSessionError("failed to decode session record protobuf"))?; - // OPTIMIZATION: Aggressively prune previous_sessions on load. - // This avoids deserializing and keeping in memory more sessions than needed. - // The constant ARCHIVED_STATES_MAX_LENGTH (40) defines the maximum we ever use, - // so any sessions beyond that are wasted memory and CPU cycles. - if record.previous_sessions.len() > consts::ARCHIVED_STATES_MAX_LENGTH { - // Keep only the most recent sessions (at the front of the vec) - record - .previous_sessions - .truncate(consts::ARCHIVED_STATES_MAX_LENGTH); - } + let limit = consts::ARCHIVED_STATES_MAX_LENGTH; + let previous_sessions: Vec = view + .previous_sessions + .iter() + .take(limit) + .map(|sv| sv.to_owned_message()) + .collect::>() + .map_err(|_| InvalidSessionError("failed to decode archived session protobuf"))?; Ok(Self { - current_session: record.current_session.map(|s| s.into()), - previous_sessions: Arc::new(record.previous_sessions), + current_session: view + .current_session + .as_option() + .map(|sv| sv.to_owned_message()) + .transpose() + .map_err(|_| InvalidSessionError("failed to decode current session protobuf"))? + .map(Into::into), + previous_sessions: Arc::new(previous_sessions), }) } @@ -841,34 +846,50 @@ impl SessionRecord { /// Encode into a caller-supplied buffer (allows reuse across flushes). pub fn serialize_into(&self, buf: &mut Vec) { - use prost::encoding::{encoded_len_varint, message::encode as encode_msg}; + use buffa::encoding::{Tag, WireType, encode_varint, varint_len}; + + fn write_len_delimited( + field: u32, + msg: &impl Message, + msg_len: usize, + cache: &mut buffa::SizeCache, + buf: &mut Vec, + ) { + Tag::new(field, WireType::LengthDelimited).encode(buf); + encode_varint(msg_len as u64, buf); + msg.write_to(cache, buf); + } - let current_len = self + let mut cache = buffa::SizeCache::new(); + let current_msg_len = self .current_session .as_ref() - .map(|s| { - let msg_len = s.session.encoded_len(); - 1 + encoded_len_varint(msg_len as u64) + msg_len - }) + .map(|s| s.session.compute_size(&mut cache) as usize); + let current_len = current_msg_len + .map(|msg_len| 1 + varint_len(msg_len as u64) + msg_len) .unwrap_or(0); + let mut previous_msg_lens = Vec::with_capacity(self.previous_sessions.len()); let previous_len: usize = self .previous_sessions .iter() .map(|s| { - let msg_len = s.encoded_len(); - 1 + encoded_len_varint(msg_len as u64) + msg_len + let msg_len = s.compute_size(&mut cache) as usize; + previous_msg_lens.push(msg_len); + 1 + varint_len(msg_len as u64) + msg_len }) .sum(); buf.clear(); buf.reserve(current_len + previous_len); - if let Some(state) = &self.current_session { - encode_msg(1, &state.session, buf); + if let Some(state) = &self.current_session + && let Some(msg_len) = current_msg_len + { + write_len_delimited(1, &state.session, msg_len, &mut cache, buf); } - for session in self.previous_sessions.iter() { - encode_msg(2, session, buf); + for (session, msg_len) in self.previous_sessions.iter().zip(previous_msg_lens) { + write_len_delimited(2, session, msg_len, &mut cache, buf); } } @@ -1014,6 +1035,25 @@ mod tests { state } + #[test] + fn set_sender_chain_key_requires_existing_sender_chain() { + let mut csprng = rng(); + let identity_keypair = KeyPair::generate(&mut csprng); + let their_identity = IdentityKey::new(identity_keypair.public_key); + let our_identity = IdentityKey::new(KeyPair::generate(&mut csprng).public_key); + let root_key = crate::protocol::ratchet::RootKey::new([0u8; 32]); + let base_key = KeyPair::generate(&mut csprng).public_key; + let mut state = SessionState::new(3, &our_identity, &their_identity, &root_key, &base_key); + let chain_key = crate::protocol::ratchet::ChainKey::new([1u8; 32], 0); + + let err = state + .set_sender_chain_key(&chain_key) + .expect_err("missing sender chain should fail"); + + assert_eq!(err.to_string(), "missing sender chain"); + assert!(!state.has_usable_sender_chain().unwrap()); + } + /// Creates a SessionRecord with N previous sessions for testing. fn create_record_with_previous_sessions(count: usize) -> SessionRecord { let mut csprng = rng(); @@ -1028,6 +1068,57 @@ mod tests { record } + fn make_cache_shape_chain(seed: u8, message_key_count: usize) -> session_structure::Chain { + let chain_key = session_structure::chain::ChainKey { + index: Some(seed as u32), + key: Some(vec![seed; 32].into()), + }; + let message_keys = (0..message_key_count) + .map(|idx| { + let idx = idx as u8; + session_structure::chain::MessageKey { + index: Some(idx as u32), + cipher_key: Some(vec![seed.wrapping_add(idx); 32].into()), + mac_key: Some(vec![seed.wrapping_add(idx).wrapping_add(1); 32].into()), + iv: Some(vec![seed.wrapping_add(idx).wrapping_add(2); 16].into()), + } + }) + .collect(); + + session_structure::Chain { + sender_ratchet_key: Some(vec![seed; 33]), + sender_ratchet_key_private: Some(vec![seed.wrapping_add(1); 32]), + chain_key: MessageField::some(chain_key), + message_keys, + } + } + + fn make_cache_shape_session( + seed: u8, + receiver_chain_count: usize, + message_key_count: usize, + ) -> SessionStructure { + let receiver_chains = (0..receiver_chain_count) + .map(|idx| make_cache_shape_chain(seed.wrapping_add(idx as u8 + 1), idx + 1)) + .collect(); + + SessionStructure { + session_version: Some(3), + local_identity_public: Some(vec![seed; 33]), + remote_identity_public: Some(vec![seed.wrapping_add(1); 33]), + root_key: Some(vec![seed.wrapping_add(2); 32]), + previous_counter: Some(seed as u32), + sender_chain: MessageField::some(make_cache_shape_chain(seed, message_key_count)), + receiver_chains, + pending_key_exchange: MessageField::none(), + pending_pre_key: MessageField::none(), + remote_registration_id: Some(10_000 + seed as u32), + local_registration_id: Some(20_000 + seed as u32), + needs_refresh: Some(seed.is_multiple_of(2)), + alice_base_key: Some(vec![seed.wrapping_add(3); 33]), + } + } + #[test] fn test_take_restore_preserves_order() { let mut record = create_record_with_previous_sessions(5); @@ -1348,6 +1439,45 @@ mod tests { assert_eq!(original_base_keys, restored_base_keys); } + #[test] + fn test_session_record_manual_encoding_matches_generated_record_structure() { + let record = create_record_with_previous_sessions(4); + let expected = waproto::whatsapp::RecordStructure { + current_session: MessageField::some( + record.current_session.as_ref().unwrap().session.clone(), + ), + previous_sessions: record.previous_sessions.as_ref().clone(), + } + .encode_to_vec(); + + assert_eq!(record.serialize().unwrap(), expected); + + let mut reused = vec![0xaa; 16]; + record.serialize_into(&mut reused); + assert_eq!(reused, expected); + } + + #[test] + fn test_session_record_manual_encoding_handles_mixed_size_cache_shapes() { + let current = make_cache_shape_session(1, 3, 2); + let previous_sessions = vec![ + make_cache_shape_session(20, 0, 5), + make_cache_shape_session(40, 6, 0), + make_cache_shape_session(60, 1, 8), + ]; + let record = SessionRecord { + current_session: Some(SessionState::from_session_structure(current.clone())), + previous_sessions: Arc::new(previous_sessions.clone()), + }; + let expected = waproto::whatsapp::RecordStructure { + current_session: MessageField::some(current), + previous_sessions, + } + .encode_to_vec(); + + assert_eq!(record.serialize().unwrap(), expected); + } + #[test] fn test_session_record_truncates_on_deserialize() { // This tests the ARCHIVED_STATES_MAX_LENGTH enforcement on load diff --git a/wacore/libsignal/src/protocol/state/signed_prekey.rs b/wacore/libsignal/src/protocol/state/signed_prekey.rs index 25d309c39..91a1d612d 100644 --- a/wacore/libsignal/src/protocol/state/signed_prekey.rs +++ b/wacore/libsignal/src/protocol/state/signed_prekey.rs @@ -6,7 +6,7 @@ use std::convert::AsRef; use std::fmt; -use prost::Message; +use buffa::Message; use crate::protocol::{ KeyPair, PrivateKey, PublicKey, Result, SignalProtocolError, Timestamp, @@ -89,7 +89,7 @@ pub trait GenericSignedPreKey { Self: Sized, { Ok(Self::from_storage( - SignedPreKeyRecordStructure::decode(data) + SignedPreKeyRecordStructure::decode_from_slice(data) .map_err(|_| SignalProtocolError::InvalidProtobufEncoding)?, )) } diff --git a/wacore/libsignal/src/protocol/stores.rs b/wacore/libsignal/src/protocol/stores.rs index 02f7278bb..417401cce 100644 --- a/wacore/libsignal/src/protocol/stores.rs +++ b/wacore/libsignal/src/protocol/stores.rs @@ -1,6 +1,6 @@ // Re-exporting structures from waproto to avoid duplication pub use waproto::whatsapp::{ - IdentityKeyPairStructure, PreKeyRecordStructure, RecordStructure, SenderKeyRecordStructure, + IdentityKeyPairStructure, PreKeyRecordStructure, SenderKeyRecordStructure, SenderKeyStateStructure, SessionStructure, SignedPreKeyRecordStructure, }; diff --git a/wacore/noise/Cargo.toml b/wacore/noise/Cargo.toml index 3819e3503..9a7ef118e 100644 --- a/wacore/noise/Cargo.toml +++ b/wacore/noise/Cargo.toml @@ -21,10 +21,10 @@ danger-skip-cert-chain-verify = [] [dependencies] anyhow = { workspace = true } +buffa = { workspace = true } bytes = { workspace = true } hkdf = { workspace = true } log = { workspace = true } -prost = { workspace = true } rand = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } @@ -34,7 +34,6 @@ waproto = { workspace = true } [dev-dependencies] divan = { workspace = true } -prost = { workspace = true } waproto = { workspace = true } [[bench]] diff --git a/wacore/noise/src/handshake.rs b/wacore/noise/src/handshake.rs index 2b1890986..178561414 100644 --- a/wacore/noise/src/handshake.rs +++ b/wacore/noise/src/handshake.rs @@ -1,6 +1,6 @@ use crate::error::NoiseError; use crate::state::{NoiseCipher, NoiseState}; -use prost::Message; +use buffa::Message; use thiserror::Error; use wacore_libsignal::protocol::{KeyPair, PrivateKey, PublicKey}; use waproto::whatsapp::cert_chain::noise_certificate; @@ -48,10 +48,8 @@ fn verify_cert_step( #[derive(Debug, Error)] pub enum HandshakeError { - #[error("Protobuf encoding/decoding error: {0}")] - Proto(#[from] prost::EncodeError), #[error("Protobuf decoding error: {0}")] - ProtoDecode(#[from] prost::DecodeError), + ProtoDecode(#[from] buffa::DecodeError), #[error("Handshake response is missing required parts")] IncompleteResponse, #[error("Crypto operation failed: {0}")] @@ -96,7 +94,7 @@ impl HandshakeUtils { /// Creates a ClientHello message with the given ephemeral key only (XX). pub fn build_client_hello(ephemeral_key: &[u8]) -> HandshakeMessage { HandshakeMessage { - client_hello: Some(wa::handshake_message::ClientHello { + client_hello: buffa::MessageField::some(wa::handshake_message::ClientHello { ephemeral: Some(ephemeral_key.to_vec()), ..Default::default() }), @@ -112,7 +110,7 @@ impl HandshakeUtils { encrypted_payload: Vec, ) -> HandshakeMessage { HandshakeMessage { - client_hello: Some(wa::handshake_message::ClientHello { + client_hello: buffa::MessageField::some(wa::handshake_message::ClientHello { ephemeral: Some(ephemeral_key.to_vec()), r#static: Some(encrypted_static), payload: Some(encrypted_payload), @@ -128,9 +126,10 @@ impl HandshakeUtils { pub fn parse_server_hello_body( response_bytes: &[u8], ) -> Result { - let handshake_response = HandshakeMessage::decode(response_bytes)?; + let handshake_response = HandshakeMessage::decode_from_slice(response_bytes)?; let server_hello = handshake_response .server_hello + .into_option() .ok_or(HandshakeError::IncompleteResponse)?; if let Some(ephemeral) = server_hello.ephemeral.as_ref() @@ -149,15 +148,18 @@ impl HandshakeUtils { /// XX-style parse: requires all three fields. Mirrors the historical /// shape used by the full XX handshake and by XX-fallback. pub fn parse_server_hello(response_bytes: &[u8]) -> Result<(Vec, Vec, Vec)> { - let server_hello = Self::parse_server_hello_body(response_bytes)?; + let mut server_hello = Self::parse_server_hello_body(response_bytes)?; let server_ephemeral = server_hello .ephemeral + .take() .ok_or(HandshakeError::IncompleteResponse)?; let server_static_ciphertext = server_hello .r#static + .take() .ok_or(HandshakeError::IncompleteResponse)?; let certificate_ciphertext = server_hello .payload + .take() .ok_or(HandshakeError::IncompleteResponse)?; Ok(( server_ephemeral, @@ -173,30 +175,31 @@ impl HandshakeUtils { cert_decrypted: &[u8], static_decrypted: &[u8; 32], ) -> Result { - let cert_chain = CertChain::decode(cert_decrypted)?; + let cert_chain = CertChain::decode_from_slice(cert_decrypted)?; let intermediate = cert_chain .intermediate + .into_option() .ok_or_else(|| HandshakeError::CertVerification("Missing intermediate cert".into()))?; let leaf = cert_chain .leaf + .into_option() .ok_or_else(|| HandshakeError::CertVerification("Missing leaf cert".into()))?; let intermediate_details_bytes = intermediate.details.as_ref().ok_or_else(|| { HandshakeError::CertVerification("Missing intermediate details".into()) })?; let intermediate_details = - noise_certificate::Details::decode(intermediate_details_bytes.as_slice())?; + noise_certificate::Details::decode_from_slice(intermediate_details_bytes.as_slice())?; - if i64::from(intermediate_details.issuer_serial()) != WA_CERT_ISSUER_SERIAL { + let issuer_serial = intermediate_details.issuer_serial.unwrap_or(0); + if i64::from(issuer_serial) != WA_CERT_ISSUER_SERIAL { return Err(HandshakeError::CertVerification(format!( - "Unexpected intermediate issuer serial: got {}, expected {}", - intermediate_details.issuer_serial(), - WA_CERT_ISSUER_SERIAL + "Unexpected intermediate issuer serial: got {issuer_serial}, expected {WA_CERT_ISSUER_SERIAL}", ))); } - let intermediate_pk_bytes = intermediate_details.key(); + let intermediate_pk_bytes = intermediate_details.key.as_deref().unwrap_or(&[]); if intermediate_pk_bytes.is_empty() { return Err(HandshakeError::CertVerification( "Intermediate details missing key".into(), @@ -218,17 +221,17 @@ impl HandshakeUtils { .details .as_ref() .ok_or_else(|| HandshakeError::CertVerification("Missing leaf details".into()))?; - let leaf_details = noise_certificate::Details::decode(leaf_details_bytes.as_slice())?; + let leaf_details = + noise_certificate::Details::decode_from_slice(leaf_details_bytes.as_slice())?; - if leaf_details.issuer_serial() != intermediate_details.serial() { + if leaf_details.issuer_serial != intermediate_details.serial { return Err(HandshakeError::CertVerification(format!( - "Leaf issuer serial mismatch: got {}, expected {}", - leaf_details.issuer_serial(), - intermediate_details.serial() + "Leaf issuer serial mismatch: got {:?}, expected {:?}", + leaf_details.issuer_serial, intermediate_details.serial ))); } - if leaf_details.key() != static_decrypted { + if leaf_details.key.as_deref().unwrap_or(&[]) != static_decrypted { return Err(HandshakeError::CertVerification( "Cert key does not match decrypted static key".into(), )); @@ -244,11 +247,11 @@ impl HandshakeUtils { Ok(VerifiedServerCertChain { intermediate_key, - intermediate_not_before: intermediate_details.not_before() as i64, - intermediate_not_after: intermediate_details.not_after() as i64, + intermediate_not_before: intermediate_details.not_before.unwrap_or(0) as i64, + intermediate_not_after: intermediate_details.not_after.unwrap_or(0) as i64, leaf_key: *static_decrypted, - leaf_not_before: leaf_details.not_before() as i64, - leaf_not_after: leaf_details.not_after() as i64, + leaf_not_before: leaf_details.not_before.unwrap_or(0) as i64, + leaf_not_after: leaf_details.not_after.unwrap_or(0) as i64, }) } @@ -257,7 +260,7 @@ impl HandshakeUtils { encrypted_payload: Vec, ) -> HandshakeMessage { HandshakeMessage { - client_finish: Some(wa::handshake_message::ClientFinish { + client_finish: buffa::MessageField::some(wa::handshake_message::ClientFinish { r#static: Some(encrypted_pubkey), payload: Some(encrypted_payload), ..Default::default() @@ -421,9 +424,7 @@ impl XxHandshakeState { pub fn build_client_hello(&self) -> Result> { let client_hello = HandshakeUtils::build_client_hello(self.ephemeral_kp.public_key.public_key_bytes()); - let mut buf = Vec::new(); - client_hello.encode(&mut buf)?; - Ok(buf) + Ok(client_hello.encode_to_vec()) } pub fn read_server_hello_and_build_client_finish( @@ -501,9 +502,7 @@ fn process_xx_server_hello_into( let encrypted_payload = noise.encrypt(payload)?; let client_finish = HandshakeUtils::build_client_finish(encrypted_pubkey, encrypted_payload); - let mut buf = Vec::new(); - client_finish.encode(&mut buf)?; - Ok(buf) + Ok(client_finish.encode_to_vec()) } /// Handshake state for **Noise IK** — used on reconnect when the device has a @@ -586,9 +585,7 @@ impl IkHandshakeState { encrypted_static, encrypted_payload, ); - let mut buf = Vec::new(); - msg.encode(&mut buf)?; - Ok(buf) + Ok(msg.encode_to_vec()) } /// `serverHello.static.is_some()` signals fallback (server rotated static). @@ -710,7 +707,6 @@ impl XxFallbackHandshakeState { #[cfg(test)] mod tests { use super::*; - use prost::Message; use wacore_binary::consts::WA_CONN_HEADER; use waproto::whatsapp as wa; @@ -754,8 +750,9 @@ mod tests { pattern: &str, prologue: &[u8], ) -> (Vec, NoiseHandshake, KeyPair, [u8; 32]) { - let msg = wa::HandshakeMessage::decode(client_hello_bytes).expect("decode hello"); - let client_eph_pub_vec = msg.client_hello.unwrap().ephemeral.unwrap(); + let msg = + wa::HandshakeMessage::decode_from_slice(client_hello_bytes).expect("decode hello"); + let client_eph_pub_vec = msg.client_hello.into_option().unwrap().ephemeral.unwrap(); let client_eph_pub: [u8; 32] = client_eph_pub_vec.try_into().unwrap(); let mut noise = NoiseHandshake::new(pattern, prologue).expect("init responder"); @@ -784,7 +781,7 @@ mod tests { .expect("enc cert"); let server_hello = wa::HandshakeMessage { - server_hello: Some(wa::handshake_message::ServerHello { + server_hello: buffa::MessageField::some(wa::handshake_message::ServerHello { ephemeral: Some(server_eph_pub.to_vec()), r#static: Some(encrypted_static), payload: Some(encrypted_payload), @@ -792,8 +789,7 @@ mod tests { }), ..Default::default() }; - let mut bytes = Vec::new(); - server_hello.encode(&mut bytes).unwrap(); + let bytes = server_hello.encode_to_vec(); (bytes, noise, server_eph, client_eph_pub) } @@ -802,8 +798,9 @@ mod tests { server_eph: KeyPair, client_finish_bytes: &[u8], ) -> (NoiseCipher, NoiseCipher) { - let msg = wa::HandshakeMessage::decode(client_finish_bytes).expect("decode finish"); - let cf = msg.client_finish.unwrap(); + let msg = + wa::HandshakeMessage::decode_from_slice(client_finish_bytes).expect("decode finish"); + let cf = msg.client_finish.into_option().unwrap(); let client_static = noise.decrypt(&cf.r#static.unwrap()).expect("dec s"); let client_static_arr: [u8; 32] = client_static.try_into().unwrap(); @@ -827,8 +824,9 @@ mod tests { prologue: &[u8], ) -> Vec { // Run IK responder side per Noise § 7.5. - let msg = wa::HandshakeMessage::decode(client_hello_bytes).expect("decode hello"); - let ch = msg.client_hello.unwrap(); + let msg = + wa::HandshakeMessage::decode_from_slice(client_hello_bytes).expect("decode hello"); + let ch = msg.client_hello.into_option().unwrap(); let client_eph_pub_vec = ch.ephemeral.unwrap(); let client_eph_pub: [u8; 32] = client_eph_pub_vec.try_into().unwrap(); let encrypted_static = ch.r#static.unwrap(); @@ -876,7 +874,7 @@ mod tests { let encrypted_cert = noise.encrypt(&responder.cert_chain_bytes).unwrap(); let server_hello = wa::HandshakeMessage { - server_hello: Some(wa::handshake_message::ServerHello { + server_hello: buffa::MessageField::some(wa::handshake_message::ServerHello { ephemeral: Some(server_eph_pub.to_vec()), r#static: None, payload: Some(encrypted_cert), @@ -884,9 +882,7 @@ mod tests { }), ..Default::default() }; - let mut bytes = Vec::new(); - server_hello.encode(&mut bytes).unwrap(); - bytes + server_hello.encode_to_vec() } /// IK responder that rejects with an XX-shaped serverHello, prompting @@ -900,8 +896,9 @@ mod tests { // Pull the client's ephemeral out of the IK clientHello to seed an // XXfallback responder. We ignore the encrypted client static and // 0-RTT payload since the client will resend on the XXfallback path. - let msg = wa::HandshakeMessage::decode(client_hello_bytes).expect("decode hello"); - let client_eph_pub_vec = msg.client_hello.unwrap().ephemeral.unwrap(); + let msg = + wa::HandshakeMessage::decode_from_slice(client_hello_bytes).expect("decode hello"); + let client_eph_pub_vec = msg.client_hello.into_option().unwrap().ephemeral.unwrap(); let client_eph_pub: [u8; 32] = client_eph_pub_vec.try_into().unwrap(); // Stand up a fresh XXfallback responder and authenticate the @@ -930,7 +927,7 @@ mod tests { let encrypted_cert = noise.encrypt(&responder.cert_chain_bytes).unwrap(); let server_hello = wa::HandshakeMessage { - server_hello: Some(wa::handshake_message::ServerHello { + server_hello: buffa::MessageField::some(wa::handshake_message::ServerHello { ephemeral: Some(server_eph_pub.to_vec()), r#static: Some(encrypted_static), payload: Some(encrypted_cert), @@ -938,8 +935,7 @@ mod tests { }), ..Default::default() }; - let mut bytes = Vec::new(); - server_hello.encode(&mut bytes).unwrap(); + let bytes = server_hello.encode_to_vec(); (bytes, noise, server_eph) } diff --git a/wacore/noise/src/test_util.rs b/wacore/noise/src/test_util.rs index 96d316ee0..74f99a7d6 100644 --- a/wacore/noise/src/test_util.rs +++ b/wacore/noise/src/test_util.rs @@ -2,7 +2,7 @@ //! integration tests. Visible only under `#[cfg(test)]` (this crate) or //! when the `test-util` feature is enabled. -use prost::Message; +use buffa::Message; use waproto::whatsapp::{self as wa, cert_chain::noise_certificate}; /// Builds a minimal `CertChain` blob whose leaf.key matches `server_static_pub`. @@ -13,7 +13,7 @@ use waproto::whatsapp::{self as wa, cert_chain::noise_certificate}; /// /// Signatures are zero-filled — the client today does NOT verify the /// intermediate's Ed25519 signature against `WA_CERT_PUB_KEY`, so the bytes -/// only need to round-trip through prost. +/// only need to round-trip through protobuf encoding. pub fn build_cert_chain_bytes(server_static_pub: &[u8; 32]) -> Vec { let intermediate_details = noise_certificate::Details { serial: Some(1), @@ -22,10 +22,7 @@ pub fn build_cert_chain_bytes(server_static_pub: &[u8; 32]) -> Vec { not_before: Some(1_700_000_000), not_after: Some(1_900_000_000), }; - let mut intermediate_details_bytes = Vec::new(); - intermediate_details - .encode(&mut intermediate_details_bytes) - .expect("encode intermediate details"); + let intermediate_details_bytes = intermediate_details.encode_to_vec(); let leaf_details = noise_certificate::Details { serial: Some(2), @@ -34,22 +31,17 @@ pub fn build_cert_chain_bytes(server_static_pub: &[u8; 32]) -> Vec { not_before: Some(1_700_000_500), not_after: Some(1_899_999_500), }; - let mut leaf_details_bytes = Vec::new(); - leaf_details - .encode(&mut leaf_details_bytes) - .expect("encode leaf details"); + let leaf_details_bytes = leaf_details.encode_to_vec(); let chain = wa::CertChain { - leaf: Some(wa::cert_chain::NoiseCertificate { + leaf: buffa::MessageField::some(wa::cert_chain::NoiseCertificate { details: Some(leaf_details_bytes), signature: Some(vec![0u8; 64]), }), - intermediate: Some(wa::cert_chain::NoiseCertificate { + intermediate: buffa::MessageField::some(wa::cert_chain::NoiseCertificate { details: Some(intermediate_details_bytes), signature: Some(vec![0u8; 64]), }), }; - let mut bytes = Vec::new(); - chain.encode(&mut bytes).expect("encode chain"); - bytes + chain.encode_to_vec() } diff --git a/wacore/noise/tests/cert_chain_verify.rs b/wacore/noise/tests/cert_chain_verify.rs index 871c34084..1c0bff4dd 100644 --- a/wacore/noise/tests/cert_chain_verify.rs +++ b/wacore/noise/tests/cert_chain_verify.rs @@ -7,7 +7,7 @@ #![cfg(not(feature = "danger-skip-cert-chain-verify"))] -use prost::Message; +use buffa::Message; use waproto::whatsapp::{self as wa, cert_chain::noise_certificate}; use wacore_noise::HandshakeUtils; @@ -23,11 +23,9 @@ fn build_zero_signed_chain(server_static_pub: &[u8; 32]) -> Vec { key: Some(vec![0xCC; 32]), not_before: Some(1_700_000_000), not_after: Some(1_900_000_000), + ..Default::default() }; - let mut intermediate_details_bytes = Vec::new(); - intermediate_details - .encode(&mut intermediate_details_bytes) - .unwrap(); + let intermediate_details_bytes = intermediate_details.encode_to_vec(); let leaf_details = noise_certificate::Details { serial: Some(2), @@ -35,23 +33,24 @@ fn build_zero_signed_chain(server_static_pub: &[u8; 32]) -> Vec { key: Some(server_static_pub.to_vec()), not_before: Some(1_700_000_500), not_after: Some(1_899_999_500), + ..Default::default() }; - let mut leaf_details_bytes = Vec::new(); - leaf_details.encode(&mut leaf_details_bytes).unwrap(); + let leaf_details_bytes = leaf_details.encode_to_vec(); let chain = wa::CertChain { - leaf: Some(wa::cert_chain::NoiseCertificate { + leaf: buffa::MessageField::some(wa::cert_chain::NoiseCertificate { details: Some(leaf_details_bytes), signature: Some(vec![0u8; 64]), + ..Default::default() }), - intermediate: Some(wa::cert_chain::NoiseCertificate { + intermediate: buffa::MessageField::some(wa::cert_chain::NoiseCertificate { details: Some(intermediate_details_bytes), signature: Some(vec![0u8; 64]), + ..Default::default() }), + ..Default::default() }; - let mut bytes = Vec::new(); - chain.encode(&mut bytes).unwrap(); - bytes + chain.encode_to_vec() } #[test] diff --git a/wacore/src/adv.rs b/wacore/src/adv.rs index b8910a739..fdb9a2630 100644 --- a/wacore/src/adv.rs +++ b/wacore/src/adv.rs @@ -7,7 +7,7 @@ use crate::libsignal::protocol::PublicKey; use crate::store::traits::DeviceInfo; -use prost::Message; +use buffa::Message; // ADV signature prefixes (WAWebAdvSignatureConstants). The hosted ([6,5]/[6,6]) // variants apply to business-hosted companion devices. @@ -57,7 +57,8 @@ pub fn validate_adv_with_identity_key( fetched_identity_key: &[u8; 32], account_identity_fallback: Option<&[u8; 32]>, ) -> AdvValidation { - let Ok(signed) = waproto::whatsapp::AdvSignedDeviceIdentity::decode(device_identity_bytes) + let Ok(signed) = + waproto::whatsapp::ADVSignedDeviceIdentity::decode_from_slice(device_identity_bytes) else { return AdvValidation::Invalid; }; @@ -122,9 +123,10 @@ pub struct DecodedKeyIndex { /// (the notification arrives over a Noise-encrypted connection, so content is /// already authenticated). pub fn decode_key_index_list(signed_bytes: &[u8]) -> Option { - let signed = waproto::whatsapp::AdvSignedKeyIndexList::decode(signed_bytes).ok()?; + let signed = waproto::whatsapp::ADVSignedKeyIndexList::decode_from_slice(signed_bytes).ok()?; let details_bytes = signed.details.as_ref()?; - let key_index = waproto::whatsapp::AdvKeyIndexList::decode(details_bytes.as_slice()).ok()?; + let key_index = + waproto::whatsapp::ADVKeyIndexList::decode_from_slice(details_bytes.as_slice()).ok()?; let raw_id = key_index.raw_id?; let timestamp = key_index.timestamp?; @@ -297,21 +299,20 @@ mod tests { #[test] fn decode_roundtrip() { - use prost::Message; + use buffa::Message; - let key_index = waproto::whatsapp::AdvKeyIndexList { + let key_index = waproto::whatsapp::ADVKeyIndexList { raw_id: Some(42), timestamp: Some(1000), current_index: Some(5), valid_indexes: vec![3, 5, 7], - account_type: None, + ..Default::default() }; let details = key_index.encode_to_vec(); - let signed = waproto::whatsapp::AdvSignedKeyIndexList { + let signed = waproto::whatsapp::ADVSignedKeyIndexList { details: Some(details), - account_signature: None, - account_signature_key: None, + ..Default::default() }; let bytes = signed.encode_to_vec(); @@ -359,7 +360,7 @@ mod tests { ) .unwrap() .to_vec(); - waproto::whatsapp::AdvSignedDeviceIdentity { + waproto::whatsapp::ADVSignedDeviceIdentity { details: Some(details.to_vec()), account_signature_key: include_account_key.then(|| account_key.to_vec()), account_signature: Some(account_sig), @@ -425,7 +426,7 @@ mod tests { let mut rng = rand::make_rng::(); let account = KeyPair::generate(&mut rng); let device = KeyPair::generate(&mut rng); - let no_dev_sig = waproto::whatsapp::AdvSignedDeviceIdentity { + let no_dev_sig = waproto::whatsapp::ADVSignedDeviceIdentity { details: Some(b"details".to_vec()), account_signature_key: Some(account.public_key.public_key_bytes().to_vec()), account_signature: Some(vec![0u8; 64]), diff --git a/wacore/src/appstate_sync.rs b/wacore/src/appstate_sync.rs index 4a480186c..4e701a0b9 100644 --- a/wacore/src/appstate_sync.rs +++ b/wacore/src/appstate_sync.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use anyhow::{Context, Result, anyhow}; use async_lock::Mutex; use async_trait::async_trait; -use prost::Message; +use buffa::Message; use thiserror::Error; use crate::appstate::hash::HashState; @@ -14,7 +14,7 @@ use crate::appstate::patch_decode::{ parse_patch_lists, parse_patch_lists_ref, }; use crate::appstate::{ - collect_key_ids_from_patch_list, expand_app_state_keys, process_patch, process_snapshot, + collect_key_id_refs_from_patch_list, expand_app_state_keys, process_patch, process_snapshot, }; use crate::store::traits::Backend; use wacore_binary::{Node, NodeRef}; @@ -25,18 +25,20 @@ pub use crate::appstate::Mutation; /// Index MAC carried by a mutation's record, if present. fn mutation_index_mac(m: &wa::SyncdMutation) -> Option<&[u8]> { - m.record.as_ref()?.index.as_ref()?.blob.as_deref() + m.record.as_option()?.index.as_option()?.blob.as_deref() } -/// Unique index MACs of a patch's mutations, in first-seen order, feeding the -/// batched previous-value-MAC backend lookup. +/// Distinct index MACs of a patch's mutations, feeding the batched +/// previous-value-MAC backend lookup. Both callers pass the result straight to a +/// `get_mutation_macs` HashMap fetch, so the order is unspecified. /// -/// Small patches use a cache-friendly linear scan (a HashSet measured 6-120% -/// slower at small N here). Patches carry up to ~1000 mutations, where the -/// scan's O(n²) compares dominate, so above [`MAC_DEDUP_SCAN_LIMIT`] dedup runs -/// through a sort of position indices — O(n log n) with only a `Vec` of -/// scratch, far cheaper than a `HashSet` of 32-byte MACs — then re-emits in -/// first-seen order. +/// Small patches dedup with a linear scan that allocates only the keepers, +/// cheaper than a sort at small N. Larger patches decode each MAC once into the +/// returned `Vec` and sort + dedup in place: the comparator works on the owned +/// bytes, never re-walking the boxed record/index/blob `MessageField` chain (the +/// part buffa makes pricier than prost's `Option` derefs), with no scratch +/// beyond the returned `Vec`. Distinct indices are the realistic patch shape, so +/// the pre-dedup `Vec` is essentially already unique. pub fn collect_unique_index_macs(mutations: &[wa::SyncdMutation]) -> Vec> { if mutations.len() <= MAC_DEDUP_SCAN_LIMIT { let mut out: Vec> = Vec::with_capacity(mutations.len()); @@ -50,33 +52,18 @@ pub fn collect_unique_index_macs(mutations: &[wa::SyncdMutation]) -> Vec return out; } - // Indices in `order` always carry a MAC, so the default branch is dead; it - // only keeps the lookup `unwrap`-free. - let mac_at = |i: u32| mutation_index_mac(&mutations[i as usize]).unwrap_or_default(); - - // Positions of mutations carrying a MAC, in first-seen order. Pre-sized to - // one allocation (the only scratch this path adds over the returned Vec). - let mut order: Vec = Vec::with_capacity(mutations.len()); - order.extend( - mutations - .iter() - .enumerate() - .filter_map(|(i, m)| mutation_index_mac(m).map(|_| i as u32)), - ); - - // Group equal MACs (ties broken by position so each run's first occurrence - // leads it), drop all but each run's leader, then restore first-seen order. - order.sort_unstable_by(|&a, &b| mac_at(a).cmp(mac_at(b)).then(a.cmp(&b))); - order.dedup_by(|&mut a, &mut b| mac_at(a) == mac_at(b)); - order.sort_unstable(); - - order.into_iter().map(|i| mac_at(i).to_vec()).collect() + let mut out: Vec> = mutations + .iter() + .filter_map(|m| mutation_index_mac(m).map(<[u8]>::to_vec)) + .collect(); + out.sort_unstable(); + out.dedup(); + out } -/// Mutation count above which [`collect_unique_index_macs`] switches from the -/// cache-friendly O(n²) linear scan to the O(n log n) index sort. Chosen well -/// below the ~1000-mutation patch ceiling and above the small-N range where the -/// scan beats sorting. +/// Mutation count at or below which [`collect_unique_index_macs`] dedups with a +/// linear scan instead of a sort; above it the sort wins despite allocating +/// every MAC before deduping. const MAC_DEDUP_SCAN_LIMIT: usize = 64; fn lookup_app_state_key( @@ -112,17 +99,21 @@ where { let data = download(ext).with_context(|| format!("download external snapshot for {name:?}"))?; - let snapshot = wa::SyncdSnapshot::decode(data.as_slice()) + let snapshot = wa::SyncdSnapshot::decode_from_slice(data.as_slice()) .with_context(|| format!("decode external snapshot for {name:?}"))?; pl.snapshot = Some(snapshot); } for patch in &mut pl.patches { - if let Some(ext) = &patch.external_mutations { - let v = patch.version.as_ref().and_then(|x| x.version).unwrap_or(0); + if let Some(ext) = patch.external_mutations.as_option() { + let v = patch + .version + .as_option() + .and_then(|x| x.version) + .unwrap_or(0); let data = download(ext) .with_context(|| format!("download external mutations for {name:?} v{v}"))?; - let ext_mutations = wa::SyncdMutations::decode(data.as_slice()) + let ext_mutations = wa::SyncdMutations::decode_from_slice(data.as_slice()) .with_context(|| format!("decode external mutations for {name:?} v{v}"))?; patch.mutations = ext_mutations.mutations; } @@ -182,10 +173,10 @@ impl AppStateProcessor { /// Pre-fetch and cache all keys needed for a patch list. async fn prefetch_keys(&self, pl: &PatchList) -> Result<()> { - let key_ids = collect_key_ids_from_patch_list(pl.snapshot.as_ref(), &pl.patches); + let key_ids = collect_key_id_refs_from_patch_list(pl.snapshot.as_ref(), &pl.patches); for key_id in key_ids { // This will fetch and cache if not already cached - let _ = self.get_app_state_key(&key_id).await; + let _ = self.get_app_state_key(key_id).await; } Ok(()) } @@ -329,7 +320,7 @@ impl AppStateProcessor { // roll the collection backward. No-op on the benign first-sync path, where snapshots // are requested only at version 0. let snapshot_fresh = pl.snapshot.as_ref().is_some_and(|snapshot| { - let snapshot_version = snapshot.version.as_ref().and_then(|v| v.version).unwrap_or(0); + let snapshot_version = snapshot.version.as_option().and_then(|v| v.version).unwrap_or(0); if snapshot_is_stale(state.version, snapshot_version) { log::warn!( target: "AppState", @@ -408,7 +399,7 @@ impl AppStateProcessor { let first_version = pl .patches .first() - .and_then(|p| p.version.as_ref()) + .and_then(|p| p.version.as_option()) .and_then(|v| v.version) .unwrap_or(0); if !pl.patches.is_empty() && first_version != 1 { @@ -567,7 +558,7 @@ impl AppStateProcessor { // Build the patch — matching whatsmeow: no Version or DeviceIndex fields let mut patch = wa::SyncdPatch { snapshot_mac: Some(snapshot_mac), - key_id: Some(wa::KeyId { + key_id: buffa::MessageField::some(wa::KeyId { id: Some(key_id.clone()), }), mutations, @@ -585,11 +576,11 @@ impl AppStateProcessor { } pub async fn get_missing_key_ids(&self, pl: &PatchList) -> Result>> { - let key_ids = collect_key_ids_from_patch_list(pl.snapshot.as_ref(), &pl.patches); + let key_ids = collect_key_id_refs_from_patch_list(pl.snapshot.as_ref(), &pl.patches); let mut missing = Vec::with_capacity(key_ids.len()); for id in key_ids { - if self.backend.get_sync_key(&id).await?.is_none() { - missing.push(id); + if self.backend.get_sync_key(id).await?.is_none() { + missing.push(id.to_vec()); } } Ok(missing) @@ -749,7 +740,7 @@ mod external_blob_tests { name: WAPatchName::Regular, has_more_patches: false, patches: vec![wa::SyncdPatch { - external_mutations: Some(wa::ExternalBlobReference { + external_mutations: buffa::MessageField::some(wa::ExternalBlobReference { direct_path: Some("/mutations".into()), ..Default::default() }), @@ -779,8 +770,8 @@ mod dedup_tests { fn mutation(index_mac: &[u8]) -> wa::SyncdMutation { wa::SyncdMutation { - record: Some(wa::SyncdRecord { - index: Some(wa::SyncdIndex { + record: buffa::MessageField::some(wa::SyncdRecord { + index: buffa::MessageField::some(wa::SyncdIndex { blob: Some(index_mac.to_vec()), }), ..Default::default() @@ -790,7 +781,7 @@ mod dedup_tests { } /// Builds `n` mutations whose index MACs repeat every `distinct` values, so - /// the expected output is the first `distinct` MACs in first-seen order. + /// the distinct set is the first `distinct` MACs. fn build(n: usize, distinct: usize) -> Vec { (0..n) .map(|i| { @@ -811,38 +802,19 @@ mod dedup_tests { (0..distinct).map(mac_bytes).collect() } - /// Both dedup paths must yield identical first-seen-order unique results; - /// the index-sort path (large N) and scan path (small N) cannot diverge. + /// Dedups to the distinct index MACs across small and large N, dropping + /// repeats. Order is unspecified (callers feed a HashMap lookup), so compare + /// as sorted sets. #[test] - fn scan_and_sort_paths_agree() { - // Small N exercises the linear scan; large N (> limit) the index sort. - for &n in &[8usize, MAC_DEDUP_SCAN_LIMIT, MAC_DEDUP_SCAN_LIMIT + 1, 1000] { + fn dedups_to_distinct_macs() { + for &n in &[8usize, 64, 65, 1000] { let distinct = (n / 2).max(1); - assert_eq!( - collect_unique_index_macs(&build(n, distinct)), - expected(distinct), - "n = {n}" - ); - } - } - - /// The index-sort path must re-emit in first-seen order, not byte-sort order. - /// Force that path (> limit MACs) with first appearances running opposite to - /// byte order, plus trailing duplicates that must be dropped — so a bug in - /// the order-restoration step can't pass by coinciding with the sort order. - #[test] - fn sort_path_restores_first_seen_order() { - let distinct = MAC_DEDUP_SCAN_LIMIT + 20; - // First-seen order is descending i; byte order is ascending (i < 256). - let mut mutations: Vec = (0..distinct) - .rev() - .map(|i| mutation(&mac_bytes(i))) - .collect(); - for i in [distinct - 1, distinct / 2, 0] { - mutations.push(mutation(&mac_bytes(i))); + let mut got = collect_unique_index_macs(&build(n, distinct)); + got.sort_unstable(); + let mut want = expected(distinct); + want.sort_unstable(); + assert_eq!(got, want, "n = {n}"); } - let want: Vec> = (0..distinct).rev().map(mac_bytes).collect(); - assert_eq!(collect_unique_index_macs(&mutations), want); } #[test] @@ -853,7 +825,8 @@ mod dedup_tests { mutation(b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), mutation(b"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), ]; - let macs = collect_unique_index_macs(&mutations); + let mut macs = collect_unique_index_macs(&mutations); + macs.sort_unstable(); assert_eq!( macs, vec![ diff --git a/wacore/src/client_profile.rs b/wacore/src/client_profile.rs index 65b1fc164..d24f5a371 100644 --- a/wacore/src/client_profile.rs +++ b/wacore/src/client_profile.rs @@ -35,7 +35,7 @@ impl Default for ClientProfile { impl ClientProfile { pub fn web() -> Self { Self { - user_agent_platform: wa::client_payload::user_agent::Platform::Web, + user_agent_platform: wa::client_payload::user_agent::Platform::WEB, device: "Desktop".to_string(), os_version: "0.1.0".to_string(), manufacturer: String::new(), @@ -49,7 +49,7 @@ impl ClientProfile { pub fn android(os_version: impl Into) -> Self { Self { - user_agent_platform: wa::client_payload::user_agent::Platform::Android, + user_agent_platform: wa::client_payload::user_agent::Platform::ANDROID, device: "Smartphone".to_string(), os_version: os_version.into(), manufacturer: String::new(), @@ -63,7 +63,7 @@ impl ClientProfile { pub fn smb_android(os_version: impl Into) -> Self { Self { - user_agent_platform: wa::client_payload::user_agent::Platform::SmbAndroid, + user_agent_platform: wa::client_payload::user_agent::Platform::SMB_ANDROID, device: "Smartphone".to_string(), os_version: os_version.into(), manufacturer: String::new(), @@ -77,7 +77,7 @@ impl ClientProfile { pub fn ios(os_version: impl Into) -> Self { Self { - user_agent_platform: wa::client_payload::user_agent::Platform::Ios, + user_agent_platform: wa::client_payload::user_agent::Platform::IOS, device: "iPhone".to_string(), os_version: os_version.into(), manufacturer: "Apple".to_string(), @@ -91,7 +91,7 @@ impl ClientProfile { pub fn macos(os_version: impl Into) -> Self { Self { - user_agent_platform: wa::client_payload::user_agent::Platform::Macos, + user_agent_platform: wa::client_payload::user_agent::Platform::MACOS, device: "Desktop".to_string(), os_version: os_version.into(), manufacturer: "Apple".to_string(), @@ -105,7 +105,7 @@ impl ClientProfile { pub fn windows(os_version: impl Into) -> Self { Self { - user_agent_platform: wa::client_payload::user_agent::Platform::Windows, + user_agent_platform: wa::client_payload::user_agent::Platform::WINDOWS, device: "Desktop".to_string(), os_version: os_version.into(), manufacturer: String::new(), @@ -127,7 +127,7 @@ mod tests { let p = ClientProfile::web(); assert_eq!( p.user_agent_platform, - wa::client_payload::user_agent::Platform::Web + wa::client_payload::user_agent::Platform::WEB ); assert_eq!(p.device, "Desktop"); assert_eq!(p.os_version, "0.1.0"); @@ -140,7 +140,7 @@ mod tests { let p = ClientProfile::android("13"); assert_eq!( p.user_agent_platform, - wa::client_payload::user_agent::Platform::Android + wa::client_payload::user_agent::Platform::ANDROID ); assert_eq!(p.os_version, "13"); assert!(!p.include_web_info); @@ -151,7 +151,7 @@ mod tests { let p = ClientProfile::smb_android("14"); assert_eq!( p.user_agent_platform, - wa::client_payload::user_agent::Platform::SmbAndroid + wa::client_payload::user_agent::Platform::SMB_ANDROID ); assert!(!p.include_web_info); } @@ -161,7 +161,7 @@ mod tests { let p = ClientProfile::ios("17.4"); assert_eq!( p.user_agent_platform, - wa::client_payload::user_agent::Platform::Ios + wa::client_payload::user_agent::Platform::IOS ); assert_eq!(p.manufacturer, "Apple"); assert!(!p.include_web_info); diff --git a/wacore/src/comment.rs b/wacore/src/comment.rs index d38e26964..7ae3b9a70 100644 --- a/wacore/src/comment.rs +++ b/wacore/src/comment.rs @@ -85,10 +85,10 @@ mod tests { fn body(text: &str) -> wa::Message { wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some(text.to_string()), ..Default::default() - })), + }), ..Default::default() } } @@ -102,7 +102,7 @@ mod tests { decrypt_comment_with_secret(&enc, &iv, &SECRET, PARENT_ID, AUTHOR, COMMENTER).unwrap(); assert_eq!( out.extended_text_message - .as_ref() + .as_option() .and_then(|m| m.text.as_deref()), Some("nice post") ); diff --git a/wacore/src/companion_reg.rs b/wacore/src/companion_reg.rs index a6e6799f8..3e970227a 100644 --- a/wacore/src/companion_reg.rs +++ b/wacore/src/companion_reg.rs @@ -89,36 +89,35 @@ pub const fn companion_web_client_type_for_platform( use CompanionWebClientType as C; use wa::device_props::PlatformType as P; match pt { - P::Chrome => C::Chrome, - P::Firefox => C::Firefox, - P::Ie => C::Ie, - P::Opera => C::Opera, - P::Safari => C::Safari, - P::Edge => C::Edge, - P::Desktop => C::Electron, - P::Uwp => C::Uwp, - P::AndroidPhone | P::AndroidTablet | P::AndroidAmbiguous => C::Chrome, - P::Unknown - | P::Ipad - | P::Ohana - | P::Aloha - | P::Catalina - | P::TclTv - | P::IosPhone - | P::IosCatalyst - | P::WearOs - | P::ArWrist - | P::ArDevice - | P::Vr - | P::CloudApi - | P::Smartglasses => C::OtherWebClient, + P::CHROME => C::Chrome, + P::FIREFOX => C::Firefox, + P::IE => C::Ie, + P::OPERA => C::Opera, + P::SAFARI => C::Safari, + P::EDGE => C::Edge, + P::DESKTOP => C::Electron, + P::UWP => C::Uwp, + P::ANDROID_PHONE | P::ANDROID_TABLET | P::ANDROID_AMBIGUOUS => C::Chrome, + P::UNKNOWN + | P::IPAD + | P::OHANA + | P::ALOHA + | P::CATALINA + | P::TCL_TV + | P::IOS_PHONE + | P::IOS_CATALYST + | P::WEAR_OS + | P::AR_WRIST + | P::AR_DEVICE + | P::VR + | P::CLOUD_API + | P::SMARTGLASSES => C::OtherWebClient, } } pub fn companion_web_client_type_for_props(props: &wa::DeviceProps) -> CompanionWebClientType { props .platform_type - .and_then(|v| wa::device_props::PlatformType::try_from(v).ok()) .map(companion_web_client_type_for_platform) .unwrap_or(CompanionWebClientType::OtherWebClient) } @@ -187,14 +186,14 @@ mod tests { use CompanionWebClientType as C; use wa::device_props::PlatformType as P; for (pt, expected) in [ - (P::Chrome, C::Chrome), - (P::Firefox, C::Firefox), - (P::Edge, C::Edge), - (P::Safari, C::Safari), - (P::Opera, C::Opera), - (P::Ie, C::Ie), - (P::Desktop, C::Electron), - (P::Uwp, C::Uwp), + (P::CHROME, C::Chrome), + (P::FIREFOX, C::Firefox), + (P::EDGE, C::Edge), + (P::SAFARI, C::Safari), + (P::OPERA, C::Opera), + (P::IE, C::Ie), + (P::DESKTOP, C::Electron), + (P::UWP, C::Uwp), ] { assert_eq!( companion_web_client_type_for_platform(pt), @@ -208,7 +207,7 @@ mod tests { fn android_platform_types_map_to_chrome() { use CompanionWebClientType as C; use wa::device_props::PlatformType as P; - for pt in [P::AndroidPhone, P::AndroidTablet, P::AndroidAmbiguous] { + for pt in [P::ANDROID_PHONE, P::ANDROID_TABLET, P::ANDROID_AMBIGUOUS] { assert_eq!( companion_web_client_type_for_platform(pt), C::Chrome, @@ -222,19 +221,19 @@ mod tests { use CompanionWebClientType as C; use wa::device_props::PlatformType as P; for pt in [ - P::Ipad, - P::IosPhone, - P::IosCatalyst, - P::WearOs, - P::ArWrist, - P::ArDevice, - P::Vr, - P::Ohana, - P::Aloha, - P::Catalina, - P::TclTv, - P::CloudApi, - P::Smartglasses, + P::IPAD, + P::IOS_PHONE, + P::IOS_CATALYST, + P::WEAR_OS, + P::AR_WRIST, + P::AR_DEVICE, + P::VR, + P::OHANA, + P::ALOHA, + P::CATALINA, + P::TCL_TV, + P::CLOUD_API, + P::SMARTGLASSES, ] { assert_eq!( companion_web_client_type_for_platform(pt), @@ -249,7 +248,7 @@ mod tests { use CompanionWebClientType as C; use wa::device_props::PlatformType as P; assert_eq!( - companion_web_client_type_for_platform(P::Unknown), + companion_web_client_type_for_platform(P::UNKNOWN), C::OtherWebClient, ); } @@ -264,7 +263,7 @@ mod tests { #[test] fn for_props_reads_platform_type() { let props = wa::DeviceProps { - platform_type: Some(wa::device_props::PlatformType::Chrome as i32), + platform_type: Some(wa::device_props::PlatformType::CHROME), ..Default::default() }; assert_eq!( @@ -284,10 +283,9 @@ mod tests { #[test] fn for_props_invalid_platform_type_is_other_web_client() { - let props = wa::DeviceProps { - platform_type: Some(9999), - ..Default::default() - }; + use buffa::Message as _; + + let props = wa::DeviceProps::decode_from_slice(&[0x18, 0x8f, 0x4e]).unwrap(); assert_eq!( companion_web_client_type_for_props(&props), CompanionWebClientType::OtherWebClient, diff --git a/wacore/src/event.rs b/wacore/src/event.rs index f06afa90b..fa14eefcf 100644 --- a/wacore/src/event.rs +++ b/wacore/src/event.rs @@ -4,7 +4,7 @@ //! `EventResponseMessage` proto and the `"Event Response"` use-case. use anyhow::{Result, ensure}; -use prost::Message; +use buffa::Message; use waproto::whatsapp::message::EventResponseMessage; use crate::secret_enc_addon::{AddonContext, ModificationType, decrypt_addon, encrypt_addon}; @@ -71,7 +71,7 @@ pub fn decrypt_event_response_with_secret( message_secret, &event_response_addon_ctx(stanza_id, event_creator_jid, responder_jid), )?; - Ok(EventResponseMessage::decode(&plaintext[..])?) + Ok(EventResponseMessage::decode_from_slice(&plaintext[..])?) } #[cfg(test)] @@ -83,7 +83,7 @@ mod tests { fn event_response_roundtrip() { let secret = [0x55u8; 32]; let resp = EventResponseMessage { - response: Some(EventResponseType::Going as i32), + response: Some(EventResponseType::Going), timestamp_ms: Some(1_700_000_000_000), extra_guest_count: Some(2), }; @@ -104,7 +104,7 @@ mod tests { "5511888888888@s.whatsapp.net", ) .unwrap(); - assert_eq!(out.response, Some(EventResponseType::Going as i32)); + assert_eq!(out.response, Some(EventResponseType::Going)); assert_eq!(out.extra_guest_count, Some(2)); } @@ -114,7 +114,7 @@ mod tests { // must fail rather than silently mis-decrypt. let secret = [0x55u8; 32]; let resp = EventResponseMessage { - response: Some(EventResponseType::Maybe as i32), + response: Some(EventResponseType::Maybe), timestamp_ms: None, extra_guest_count: None, }; diff --git a/wacore/src/history_sync.rs b/wacore/src/history_sync.rs index 0e4ebe4a8..b61277de9 100644 --- a/wacore/src/history_sync.rs +++ b/wacore/src/history_sync.rs @@ -12,7 +12,7 @@ pub enum HistorySyncError { #[error("Failed to decompress history sync data: {0}")] DecompressionError(#[from] std::io::Error), #[error("Failed to decode HistorySync protobuf: {0}")] - ProtobufDecodeError(#[from] prost::DecodeError), + ProtobufDecodeError(#[from] buffa::DecodeError), #[error("Malformed protobuf: {0}")] MalformedProtobuf(String), /// [`HistorySyncStream::remainder`] was called while the tail still held a @@ -406,14 +406,14 @@ impl<'a> HistorySyncStream<'a> { } /// Decoded variant of [`HistorySyncStream::next_conversation_bytes`]. - /// LENIENT: a conversation that fails prost decode is skipped and counted + /// LENIENT: a conversation that fails to decode is skipped and counted /// in [`HistorySyncStream::skipped_conversations`], not fatal — one /// corrupt entry doesn't void the rest of the blob. pub fn next_conversation(&mut self) -> Result, HistorySyncError> { loop { match self.next_conversation_bytes()? { None => return Ok(None), - Some(bytes) => match ::decode(bytes) { + Some(bytes) => match waproto::codec::conversation_decode(bytes) { Ok(conversation) => return Ok(Some(conversation)), Err(e) => { log::debug!("Skipping undecodable history-sync conversation: {e}"); @@ -487,8 +487,7 @@ fn read_varint(data: &[u8]) -> Option<(u64, usize)> { let mut value = (first & 0x7F) as u64; let mut shift = 7u32; for (i, &byte) in data[1..].iter().enumerate() { - // The 10th byte of a 64-bit varint may only carry one bit; prost - // rejects the overflow instead of silently truncating it. + // The 10th byte of a 64-bit varint may only carry one bit. if shift == 63 && byte > 1 { return None; } @@ -537,8 +536,7 @@ fn skip_field(wire_type: u32, buf: &[u8], pos: usize) -> Result Option { let mut pos = 0; let mut id_match = false; @@ -551,7 +549,6 @@ fn extract_own_pushname(data: &[u8], own_user: &str) -> Option { let wt = (tag & 0x7) as u32; match field_number { - // id (string) tags::pushname::ID if wt == wire_type::LENGTH_DELIMITED => { let (len, vlen) = read_varint(data.get(pos..)?)?; pos += vlen; @@ -564,7 +561,6 @@ fn extract_own_pushname(data: &[u8], own_user: &str) -> Option { } pos = end; } - // pushname (string) tags::pushname::PUSHNAME if wt == wire_type::LENGTH_DELIMITED => { let (len, vlen) = read_varint(data.get(pos..)?)?; pos += vlen; @@ -583,176 +579,10 @@ fn extract_own_pushname(data: &[u8], own_user: &str) -> Option { if id_match { pushname } else { None } } -#[derive(Clone, PartialEq, prost::Message)] -pub(crate) struct HistorySyncMsgInternalFields { - // Decoded one message at a time (see `extract_conversation_fields`), so this - // is a short-lived stack value rather than an element of a big Vec — no box - // needed. - #[prost(message, optional, tag = "1")] - pub message: Option, -} - -#[derive(Clone, PartialEq, prost::Message)] -pub(crate) struct WebMessageInfoInternalFields { - #[prost(message, optional, tag = "1")] - pub key: Option, - #[prost(message, optional, tag = "2")] - pub message: Option, - /// Parent message event time (unix seconds). Drives msg-secret retention - /// so a horizon expires by the message's real age, not when we seeded it. - #[prost(uint64, optional, tag = "3")] - pub message_timestamp: Option, - #[prost(string, optional, tag = "5")] - pub participant: Option, - #[prost(bytes = "vec", optional, tag = "49")] - pub message_secret: Option>, -} - -#[derive(Clone, PartialEq, prost::Message)] -pub(crate) struct MessageKeyInternalFields { - #[prost(bool, optional, tag = "2")] - pub from_me: Option, - #[prost(string, optional, tag = "3")] - pub id: Option, - #[prost(string, optional, tag = "4")] - pub participant: Option, -} - -#[derive(Clone, PartialEq, prost::Message)] -pub(crate) struct MessageInternalFields { - #[prost(message, optional, tag = "3")] - pub image_message: Option, - #[prost(message, optional, tag = "4")] - pub contact_message: Option, - #[prost(message, optional, tag = "5")] - pub location_message: Option, - #[prost(message, optional, tag = "6")] - pub extended_text_message: Option, - #[prost(message, optional, tag = "7")] - pub document_message: Option, - #[prost(message, optional, tag = "8")] - pub audio_message: Option, - #[prost(message, optional, tag = "9")] - pub video_message: Option, - #[prost(message, optional, tag = "13")] - pub contacts_array_message: Option, - #[prost(message, optional, tag = "18")] - pub live_location_message: Option, - #[prost(message, optional, tag = "25")] - pub template_message: Option, - #[prost(message, optional, tag = "26")] - pub sticker_message: Option, - #[prost(message, optional, tag = "28")] - pub group_invite_message: Option, - #[prost(message, optional, tag = "29")] - pub template_button_reply_message: Option, - #[prost(message, optional, tag = "30")] - pub product_message: Option, - #[prost(message, optional, tag = "31")] - pub device_sent_message: Option, - #[prost(message, optional, tag = "35")] - pub message_context_info: Option, - #[prost(message, optional, tag = "36")] - pub list_message: Option, - #[prost(message, optional, tag = "37")] - pub view_once_message: Option, - #[prost(message, optional, tag = "38")] - pub order_message: Option, - #[prost(message, optional, tag = "39")] - pub list_response_message: Option, - #[prost(message, optional, tag = "40")] - pub ephemeral_message: Option, - #[prost(message, optional, tag = "42")] - pub buttons_message: Option, - #[prost(message, optional, tag = "43")] - pub buttons_response_message: Option, - #[prost(message, optional, tag = "45")] - pub interactive_message: Option, - #[prost(message, optional, tag = "48")] - pub interactive_response_message: Option, - #[prost(message, optional, tag = "49")] - pub poll_creation_message: Option, - #[prost(message, optional, tag = "53")] - pub document_with_caption_message: Option, - #[prost(message, optional, tag = "55")] - pub view_once_message_v2: Option, - #[prost(message, optional, tag = "58")] - pub edited_message: Option, - #[prost(message, optional, tag = "60")] - pub poll_creation_message_v2: Option, - #[prost(message, optional, tag = "64")] - pub poll_creation_message_v3: Option, - #[prost(message, optional, tag = "75")] - pub event_message: Option, - #[prost(message, optional, tag = "78")] - pub newsletter_admin_invite_message: Option, - #[prost(message, optional, tag = "86")] - pub sticker_pack_message: Option, -} - -#[derive(Clone, PartialEq, prost::Message)] -pub(crate) struct MessageContextInfoInternalFields { - #[prost(bytes = "vec", optional, tag = "3")] - pub message_secret: Option>, - /// Raw `BotMetadata` bytes; only its presence matters (a bot invocation), - /// so it stays opaque to keep the partial decode cheap. - #[prost(bytes = "vec", optional, tag = "7")] - pub bot_metadata: Option>, -} - -macro_rules! define_context_info_carrier { - ($name:ident, $tag:literal) => { - #[derive(Clone, PartialEq, prost::Message)] - pub(crate) struct $name { - #[prost(message, optional, tag = $tag)] - pub context_info: Option, - } - - impl $name { - fn is_forwarded(&self) -> bool { - self.context_info - .as_ref() - .and_then(|ctx| ctx.is_forwarded) - .unwrap_or(false) - } - } - }; -} - -define_context_info_carrier!(ContextInfoTag1InternalFields, "1"); -define_context_info_carrier!(ContextInfoTag3InternalFields, "3"); -define_context_info_carrier!(ContextInfoTag4InternalFields, "4"); -define_context_info_carrier!(ContextInfoTag5InternalFields, "5"); -define_context_info_carrier!(ContextInfoTag6InternalFields, "6"); -define_context_info_carrier!(ContextInfoTag7InternalFields, "7"); -define_context_info_carrier!(ContextInfoTag8InternalFields, "8"); -define_context_info_carrier!(ContextInfoTag11InternalFields, "11"); -define_context_info_carrier!(ContextInfoTag15InternalFields, "15"); -define_context_info_carrier!(ContextInfoTag17InternalFields, "17"); - -#[derive(Clone, PartialEq, prost::Message)] -pub(crate) struct ContextInfoInternalFields { - #[prost(bool, optional, tag = "22")] - pub is_forwarded: Option, -} - -#[derive(Clone, PartialEq, prost::Message)] -pub(crate) struct DeviceSentMessageInternalFields { - #[prost(message, optional, boxed, tag = "2")] - pub message: Option>, -} - -#[derive(Clone, PartialEq, prost::Message)] -pub(crate) struct FutureProofMessageInternalFields { - #[prost(message, optional, boxed, tag = "1")] - pub message: Option>, -} - -// Schema pinning for every hand-written `#[prost(tag)]` literal above (prost -// attributes only accept literals, so they cannot reference the generated -// consts directly). If whatsapp.proto renumbers, renames or removes any of -// these fields, compilation fails here instead of the partial decoder silently -// reading the wrong wire field. +// Schema pinning: assert that the tags::* constants used by fast_extract and +// extract_conversation_fields match the generated proto field numbers. If +// whatsapp.proto renumbers a field, compilation fails here instead of the +// fast-path silently reading the wrong wire field. const _: () = { assert!(tags::history_sync_msg::MESSAGE == 1); @@ -809,22 +639,20 @@ const _: () = { assert!(tags::message::device_sent_message::MESSAGE == 2); assert!(tags::message::future_proof_message::MESSAGE == 1); - // ContextInfoTagN carriers: pin the `contextInfo` field number of every - // proto message each carrier stands in for. - assert!(tags::message::event_message::CONTEXT_INFO == 1); // Tag1 - assert!(tags::message::template_message::CONTEXT_INFO == 3); // Tag3 + assert!(tags::message::event_message::CONTEXT_INFO == 1); + assert!(tags::message::template_message::CONTEXT_INFO == 3); assert!(tags::message::template_button_reply_message::CONTEXT_INFO == 3); assert!(tags::message::buttons_response_message::CONTEXT_INFO == 3); - assert!(tags::message::list_response_message::CONTEXT_INFO == 4); // Tag4 - assert!(tags::message::poll_creation_message::CONTEXT_INFO == 5); // Tag5 (v2/v3 share the type) - assert!(tags::message::newsletter_admin_invite_message::CONTEXT_INFO == 6); // Tag6 - assert!(tags::message::group_invite_message::CONTEXT_INFO == 7); // Tag7 - assert!(tags::message::list_message::CONTEXT_INFO == 8); // Tag8 + assert!(tags::message::list_response_message::CONTEXT_INFO == 4); + assert!(tags::message::poll_creation_message::CONTEXT_INFO == 5); + assert!(tags::message::newsletter_admin_invite_message::CONTEXT_INFO == 6); + assert!(tags::message::group_invite_message::CONTEXT_INFO == 7); + assert!(tags::message::list_message::CONTEXT_INFO == 8); assert!(tags::message::buttons_message::CONTEXT_INFO == 8); - assert!(tags::message::sticker_pack_message::CONTEXT_INFO == 11); // Tag11 - assert!(tags::message::interactive_message::CONTEXT_INFO == 15); // Tag15 + assert!(tags::message::sticker_pack_message::CONTEXT_INFO == 11); + assert!(tags::message::interactive_message::CONTEXT_INFO == 15); assert!(tags::message::interactive_response_message::CONTEXT_INFO == 15); - assert!(tags::message::image_message::CONTEXT_INFO == 17); // Tag17 + assert!(tags::message::image_message::CONTEXT_INFO == 17); assert!(tags::message::contact_message::CONTEXT_INFO == 17); assert!(tags::message::location_message::CONTEXT_INFO == 17); assert!(tags::message::extended_text_message::CONTEXT_INFO == 17); @@ -838,115 +666,6 @@ const _: () = { assert!(tags::message::order_message::CONTEXT_INFO == 17); }; -impl MessageInternalFields { - fn base_message(&self) -> &Self { - let mut current = self; - loop { - let next = current - .device_sent_message - .as_ref() - .and_then(|m| m.message.as_deref()) - .or_else(|| { - current - .ephemeral_message - .as_ref() - .and_then(|m| m.message.as_deref()) - }) - .or_else(|| { - current - .view_once_message - .as_ref() - .and_then(|m| m.message.as_deref()) - }) - .or_else(|| { - current - .view_once_message_v2 - .as_ref() - .and_then(|m| m.message.as_deref()) - }) - .or_else(|| { - current - .document_with_caption_message - .as_ref() - .and_then(|m| m.message.as_deref()) - }) - .or_else(|| { - current - .edited_message - .as_ref() - .and_then(|m| m.message.as_deref()) - }); - - match next { - Some(msg) => current = msg, - None => return current, - } - } - } - - /// Whether the message invokes a bot, detected via `botMetadata` presence. - /// botMetadata sits on the top-level `MessageContextInfo` even when wrapped, - /// so check both the outer message and the unwrapped base. (Mentions are not - /// decoded in this partial path; a mention-only prompt falls back to text.) - fn invokes_bot(&self) -> bool { - let has = |m: &Self| { - m.message_context_info - .as_ref() - .is_some_and(|c| c.bot_metadata.is_some()) - }; - has(self) || has(self.base_message()) - } - - /// Whether the (unwrapped) message is a poll-creation or event message. - /// These carry the longer poll/event retention horizon. - fn is_poll_or_event(&self) -> bool { - let base = self.base_message(); - base.poll_creation_message.is_some() - || base.poll_creation_message_v2.is_some() - || base.poll_creation_message_v3.is_some() - || base.event_message.is_some() - } - - fn is_forwarded(&self) -> bool { - let base = self.base_message(); - macro_rules! any_forwarded { - ($($field:ident),+ $(,)?) => { - false $(|| base.$field.as_ref().map(|m| m.is_forwarded()).unwrap_or(false))+ - }; - } - - any_forwarded!( - extended_text_message, - image_message, - video_message, - audio_message, - document_message, - sticker_message, - location_message, - live_location_message, - contact_message, - contacts_array_message, - buttons_message, - buttons_response_message, - list_message, - list_response_message, - template_message, - template_button_reply_message, - interactive_message, - interactive_response_message, - poll_creation_message, - poll_creation_message_v2, - poll_creation_message_v3, - product_message, - order_message, - group_invite_message, - event_message, - sticker_pack_message, - newsletter_admin_invite_message, - ) - } -} - /// Message secret bytes, inline up to 32 bytes (the universal size of real /// message secrets) so extracting a record costs no heap allocation. Larger /// payloads spill to the heap, preserving arbitrary-length wire semantics. @@ -1474,15 +1193,17 @@ fn scan_message_level(msg: &[u8]) -> Result, WalkStop> { Ok(level) } +/// Max wrapper layers to unwrap before treating a message as its own base. +/// Shared by the fast lazy walk and the full-decode fallback so they classify +/// (poll/forwarded/bot) the same base at every depth; prost had no cap but +/// aborted decode near its recursion limit, and real messages nest few levels. +const MAX_MESSAGE_WRAP_DEPTH: usize = 40; + /// Follow the wrapper chain (device-sent/ephemeral/view-once/...) to the base /// message, mirroring `MessageInternalFields::base_message`: at each level the /// first wrapper in priority order that has an inner message wins. fn unwrap_to_base(mut level: MsgLevel<'_>) -> Result, WalkStop> { - // prost aborts decode past its recursion limit (no record); deeper chains - // defer to it rather than re-deriving the exact cutoff here. - const MAX_UNWRAPS: usize = 40; - - for _ in 0..MAX_UNWRAPS { + for _ in 0..MAX_MESSAGE_WRAP_DEPTH { let mut next: Option<&[u8]> = None; for (&inner_tag, wrapper) in WRAPPER_INNER_TAGS.iter().zip(level.wrappers) { let Some(wrapper) = wrapper else { @@ -1534,7 +1255,7 @@ fn scan_context_info(mci: &[u8]) -> Result<(Option<&[u8]>, bool), WalkStop> { } /// Message-secret data extracted from a conversation during streaming. -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Eq)] pub struct HistoryMsgSecretRecord { /// Conversation JID. `Arc` because every record of a conversation /// shares the same id: one allocation per conversation, not per record. @@ -1562,19 +1283,18 @@ pub struct HistoryMsgSecretRecord { /// Partial reader for one conversation: walks its protobuf fields directly /// (id, messages[], tctoken trio) and decodes each `HistorySyncMsg` ONE AT A /// TIME, extracting its secret record and dropping it immediately. This avoids -/// materializing the whole `Vec` (and a heap -/// allocation per message) just to scan it — only one message is decoded at a -/// time. The complex per-message flag logic stays in prost via -/// `HistorySyncMsgInternalFields`. +/// materializing the whole `Vec` just to scan it — only one +/// message is decoded at a time. /// -/// Best-effort on malformed bytes: stops at the first bad field, keeping records -/// already extracted (a malformed tail no longer discards a whole conversation). +/// Best-effort, mirroring the pre-buffa per-message decode: a single malformed +/// message is skipped without discarding the rest of the conversation OR its +/// tctoken. Decoding the whole conversation as one view (all-or-nothing) would +/// drop every record + the tctoken on any single bad message, and would also +/// materialize the entire conversation tree at once. fn extract_conversation_fields( data: &[u8], - secrets_out: &mut Vec, + records: &mut Vec, ) -> Option { - use prost::Message; - let mut pos = 0; // Conversation.id precedes messages/tctoken in tag order, so it is // captured before any message is processed. @@ -1629,7 +1349,7 @@ fn extract_conversation_fields( match fast_extract(&data[pos..end]) { FastExtract::NoRecord => {} FastExtract::Record(r) => { - secrets_out.push(HistoryMsgSecretRecord { + records.push(HistoryMsgSecretRecord { chat_id: chat_id_shared .get_or_insert_with(|| Arc::from(chat_id)) .clone(), @@ -1644,10 +1364,12 @@ fn extract_conversation_fields( }); } // Rare wire shapes (repeated message-typed fields, - // pathological nesting): prost merge is the oracle. + // pathological nesting): fall back to full decode. FastExtract::Fallback => { - if let Ok(msg) = HistorySyncMsgInternalFields::decode(&data[pos..end]) { - push_secret_record(chat_id, &mut chat_id_shared, msg, secrets_out); + if let Ok(msg) = + waproto::codec::history_sync_msg_decode(&data[pos..end]) + { + push_secret_record(chat_id, &mut chat_id_shared, msg, records); } } } @@ -1705,86 +1427,203 @@ fn extract_conversation_fields( }) } -/// Extract a single message's secret record (if any) into `out`. The decode + -/// forwarded/poll/bot detection stays in prost via the typed fields/methods. +/// Extract a single message's secret record (if any) into `out`. /// `chat_id_shared` memoizes the conversation id `Arc` so it is allocated only /// when the first record is actually pushed. fn push_secret_record( chat_id: &str, chat_id_shared: &mut Option>, - mut history_msg: HistorySyncMsgInternalFields, + history_msg: wa::HistorySyncMsg, out: &mut Vec, ) { - // Takes `history_msg` by value: the message is decoded fresh per record and - // dropped right after, so the owned fields are moved into the record instead - // of cloned. - let Some(web_msg) = history_msg.message.as_mut() else { + let Some(web_msg) = history_msg.message.as_option() else { return; }; - let Some(key) = web_msg.key.as_ref() else { + let Some(key) = web_msg.key.as_option() else { return; }; - if key.id.is_none() { + let Some(msg_id) = key.id.as_deref() else { return; - } - let from_me = key.from_me == Some(true); - - if let Some(message) = web_msg.message.as_ref() - && message.is_forwarded() + }; + if web_msg + .message + .as_option() + .is_some_and(message_is_forwarded) { return; } - - // Read the Copy-flag fields by borrow before moving any owned field out. - let is_poll_or_event = web_msg - .message - .as_ref() - .map(|m| m.is_poll_or_event()) - .unwrap_or(false); - let is_bot_invocation = web_msg - .message - .as_ref() - .map(|m| m.invokes_bot()) - .unwrap_or(false); - let timestamp = web_msg.message_timestamp; - - // Top-level message_secret takes priority over the context-info one (same - // order as the previous `or_else`); take it rather than clone. - let secret = if web_msg.message_secret.is_some() { - web_msg.message_secret.take() - } else { + let Some(secret) = web_msg.message_secret.as_deref().or_else(|| { web_msg .message - .as_mut() - .and_then(|m| m.message_context_info.as_mut()) - .and_then(|mci| mci.message_secret.take()) - }; - let Some(secret) = secret else { + .as_option() + .and_then(extract_message_context_secret) + }) else { return; }; - let key = web_msg.key.as_mut().expect("key presence checked above"); - let msg_id = key.id.take().expect("id presence checked above"); - let key_participant = key.participant.take(); - let web_msg_participant = web_msg.participant.take(); + let inner = web_msg.message.as_option(); + let is_poll_or_event = inner.is_some_and(message_is_poll_or_event); + let is_bot_invocation = inner.is_some_and(message_invokes_bot); out.push(HistoryMsgSecretRecord { chat_id: chat_id_shared .get_or_insert_with(|| Arc::from(chat_id)) .clone(), - from_me, - key_participant, - web_msg_participant, + from_me: key.from_me.unwrap_or(false), + key_participant: key.participant.clone(), + web_msg_participant: web_msg.participant.clone(), msg_id: msg_id.into(), secret: secret.into(), - timestamp, + timestamp: web_msg.message_timestamp, is_poll_or_event, is_bot_invocation, }); } +fn base_message_view(message: &wa::Message) -> &wa::Message { + let mut current = message; + let mut depth = 0usize; + while depth < MAX_MESSAGE_WRAP_DEPTH { + match first_wrapped_message(current) { + Some(inner) => { + current = inner; + depth += 1; + } + None => break, + } + } + current +} + +/// Whether the (unwrapped) message is a poll-creation or event message. These +/// carry the longer poll/event retention horizon. +fn message_is_poll_or_event(message: &wa::Message) -> bool { + let base = base_message_view(message); + base.poll_creation_message.as_option().is_some() + || base.poll_creation_message_v2.as_option().is_some() + || base.poll_creation_message_v3.as_option().is_some() + || base.event_message.as_option().is_some() +} + +/// Whether the message invokes a bot, detected via `botMetadata` presence. +/// botMetadata sits on the top-level `MessageContextInfo` even when wrapped, +/// so check both the outer message and the unwrapped base. +fn message_invokes_bot(message: &wa::Message) -> bool { + let has = |m: &wa::Message| { + m.message_context_info + .as_option() + .is_some_and(|c| c.bot_metadata.as_option().is_some()) + }; + has(message) || has(base_message_view(message)) +} + +fn extract_message_context_secret(message: &wa::Message) -> Option<&[u8]> { + message + .message_context_info + .as_option()? + .message_secret + .as_deref() +} + +fn message_is_forwarded(message: &wa::Message) -> bool { + message_is_forwarded_at_depth(message, 0) +} + +fn message_is_forwarded_at_depth(message: &wa::Message, depth: usize) -> bool { + if depth >= MAX_MESSAGE_WRAP_DEPTH { + return false; + } + + if let Some(inner) = first_wrapped_message(message) { + return message_is_forwarded_at_depth(inner, depth + 1); + } + + message_context_is_forwarded(message) +} + +fn first_wrapped_message(message: &wa::Message) -> Option<&wa::Message> { + if let Some(wrapper) = message.device_sent_message.as_option() + && let Some(inner) = wrapper.message.as_option() + { + return Some(inner); + } + + macro_rules! future_proof_inner { + ($($field:ident),* $(,)?) => { + $( + if let Some(wrapper) = message.$field.as_option() + && let Some(inner) = wrapper.message.as_option() + { + return Some(inner); + } + )* + }; + } + + future_proof_inner!( + ephemeral_message, + view_once_message, + view_once_message_v2, + document_with_caption_message, + edited_message, + ); + + None +} + +fn message_context_is_forwarded(message: &wa::Message) -> bool { + macro_rules! has_forwarded_context { + ($($field:ident),* $(,)?) => { + $( + if message.$field.as_option() + .and_then(|m| m.context_info.as_option()) + .is_some_and(context_info_is_forwarded) + { + return true; + } + )* + }; + } + + has_forwarded_context!( + event_message, + template_message, + template_button_reply_message, + buttons_response_message, + list_response_message, + poll_creation_message, + poll_creation_message_v2, + poll_creation_message_v3, + newsletter_admin_invite_message, + group_invite_message, + list_message, + buttons_message, + sticker_pack_message, + interactive_message, + interactive_response_message, + image_message, + contact_message, + location_message, + extended_text_message, + document_message, + audio_message, + video_message, + contacts_array_message, + live_location_message, + sticker_message, + product_message, + order_message, + ); + + false +} + +fn context_info_is_forwarded(context: &wa::ContextInfo) -> bool { + context.is_forwarded == Some(true) +} + /// Tctoken data extracted from a conversation during streaming. -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Eq)] pub struct TcTokenCandidate { pub id: String, pub tc_token: Vec, @@ -1795,9 +1634,9 @@ pub struct TcTokenCandidate { #[cfg(test)] mod tests { use super::*; + use buffa::Message; use flate2::Compression; use flate2::write::ZlibEncoder; - use prost::Message; use std::io::Write; use waproto::whatsapp as wa; @@ -1814,7 +1653,7 @@ mod tests { fn oracle_records(raw_msg: &[u8]) -> Vec { let mut out = Vec::new(); let mut shared = None; - if let Ok(msg) = HistorySyncMsgInternalFields::decode(raw_msg) { + if let Ok(msg) = wa::HistorySyncMsg::decode_from_slice(raw_msg) { push_secret_record("5511777776666@s.whatsapp.net", &mut shared, msg, &mut out); } out @@ -1840,7 +1679,7 @@ mod tests { fn wrap_in_history_msg(web_msg: &wa::WebMessageInfo) -> Vec { wa::HistorySyncMsg { - message: Some(Box::new(web_msg.clone())), + message: buffa::MessageField::some(web_msg.clone()), ..Default::default() } .encode_to_vec() @@ -1855,28 +1694,28 @@ mod tests { fn keyed(id: &str, from_me: bool, message: Option) -> wa::WebMessageInfo { wa::WebMessageInfo { - key: wa::MessageKey { + key: buffa::MessageField::some(wa::MessageKey { from_me: Some(from_me), id: Some(id.to_string()), ..Default::default() - }, - message: message.map(Box::new), + }), + message: message.map(buffa::MessageField::some).unwrap_or_default(), message_timestamp: Some(1_700_000_777), ..Default::default() } } - fn fp(inner: wa::Message) -> Box { - Box::new(wa::message::FutureProofMessage { - message: Some(Box::new(inner)), - }) + fn fp(inner: wa::Message) -> wa::message::FutureProofMessage { + wa::message::FutureProofMessage { + message: buffa::MessageField::some(inner), + } } - /// Differential corpus: every structurally interesting shape, prost-built + /// Differential corpus: every structurally interesting shape, buffa-built /// and hand-crafted, must extract identically through the fast path and - /// the prost oracle. + /// the full-decode oracle. #[test] - fn differential_fast_path_matches_prost_oracle() { + fn differential_fast_path_matches_full_decode_oracle() { let emit = emit_len_field; let secret = vec![0x5Au8; 32]; let mut corpus: Vec<(String, Vec)> = Vec::new(); @@ -1903,7 +1742,7 @@ mod tests { "A3", false, Some(wa::Message { - message_context_info: Some(Box::new(secret_ctx(&secret))), + message_context_info: buffa::MessageField::some(secret_ctx(&secret)), ..Default::default() }), )), @@ -1912,7 +1751,7 @@ mod tests { "A4", false, Some(wa::Message { - message_context_info: Some(Box::new(secret_ctx(&[0xBB; 32]))), + message_context_info: buffa::MessageField::some(secret_ctx(&[0xBB; 32])), ..Default::default() }), ); @@ -1925,15 +1764,17 @@ mod tests { "A5", false, Some(wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { - text: Some("x".into()), - context_info: Some(Box::new(wa::ContextInfo { - is_forwarded: Some(fwd), + extended_text_message: buffa::MessageField::some( + wa::message::ExtendedTextMessage { + text: Some("x".into()), + context_info: buffa::MessageField::some(wa::ContextInfo { + is_forwarded: Some(fwd), + ..Default::default() + }), ..Default::default() - })), - ..Default::default() - })), - message_context_info: Some(Box::new(secret_ctx(&secret))), + }, + ), + message_context_info: buffa::MessageField::some(secret_ctx(&secret)), ..Default::default() }), )), @@ -1944,14 +1785,14 @@ mod tests { "A6", false, Some(wa::Message { - ephemeral_message: Some(fp(wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage { - context_info: Some(Box::new(wa::ContextInfo { + ephemeral_message: buffa::MessageField::some(fp(wa::Message { + image_message: buffa::MessageField::some(wa::message::ImageMessage { + context_info: buffa::MessageField::some(wa::ContextInfo { is_forwarded: Some(true), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() })), ..Default::default() @@ -1966,14 +1807,16 @@ mod tests { "A7", false, Some(wa::Message { - view_once_message_v2: Some(fp(wa::Message { - poll_creation_message: Some(Box::new(wa::message::PollCreationMessage { - name: Some("poll".into()), - ..Default::default() - })), + view_once_message_v2: buffa::MessageField::some(fp(wa::Message { + poll_creation_message: buffa::MessageField::some( + wa::message::PollCreationMessage { + name: Some("poll".into()), + ..Default::default() + }, + ), ..Default::default() })), - message_context_info: Some(Box::new(secret_ctx(&secret))), + message_context_info: buffa::MessageField::some(secret_ctx(&secret)), ..Default::default() }), )), @@ -1983,20 +1826,24 @@ mod tests { "A8", true, Some(wa::Message { - device_sent_message: Some(Box::new(wa::message::DeviceSentMessage { - destination_jid: Some("5511777776666@s.whatsapp.net".into()), - message: Some(Box::new(wa::Message { - ephemeral_message: Some(fp(wa::Message { - event_message: Some(Box::new(wa::message::EventMessage { - name: Some("ev".into()), + device_sent_message: buffa::MessageField::some( + wa::message::DeviceSentMessage { + destination_jid: Some("5511777776666@s.whatsapp.net".into()), + message: buffa::MessageField::some(wa::Message { + ephemeral_message: buffa::MessageField::some(fp(wa::Message { + event_message: buffa::MessageField::some( + wa::message::EventMessage { + name: Some("ev".into()), + ..Default::default() + }, + ), ..Default::default() })), ..Default::default() - })), - ..Default::default() - })), - phash: None, - })), + }), + phash: None, + }, + ), ..Default::default() }), ); @@ -2009,11 +1856,11 @@ mod tests { "A9", false, Some(wa::Message { - message_context_info: Some(Box::new(wa::MessageContextInfo { + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(secret.clone()), - bot_metadata: Some(wa::BotMetadata::default()), + bot_metadata: buffa::MessageField::some(wa::BotMetadata::default()), ..Default::default() - })), + }), ..Default::default() }), )), @@ -2023,11 +1870,11 @@ mod tests { "A10", false, Some(wa::Message { - ephemeral_message: Some(fp(wa::Message { - message_context_info: Some(Box::new(wa::MessageContextInfo { - bot_metadata: Some(wa::BotMetadata::default()), + ephemeral_message: buffa::MessageField::some(fp(wa::Message { + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { + bot_metadata: buffa::MessageField::some(wa::BotMetadata::default()), ..Default::default() - })), + }), ..Default::default() })), ..Default::default() @@ -2042,26 +1889,26 @@ mod tests { "A11", false, Some(wa::Message { - edited_message: Some(fp(wa::Message { - poll_creation_message_v3: Some(Box::new( + edited_message: buffa::MessageField::some(fp(wa::Message { + poll_creation_message_v3: buffa::MessageField::some( wa::message::PollCreationMessage::default(), - )), + ), ..Default::default() })), - message_context_info: Some(Box::new(secret_ctx(&secret))), + message_context_info: buffa::MessageField::some(secret_ctx(&secret)), ..Default::default() }), )), ); add("missing key id, top secret", { - let mut wm = wa::WebMessageInfo { - key: wa::MessageKey { + let wm = wa::WebMessageInfo { + key: buffa::MessageField::some(wa::MessageKey { from_me: Some(false), ..Default::default() - }, + }), + message_secret: Some(secret.clone()), ..Default::default() }; - wm.message_secret = Some(secret.clone()); wrap_in_history_msg(&wm) }); add("no key at all, top secret", { @@ -2072,10 +1919,18 @@ mod tests { wrap_in_history_msg(&wm) }); add("participants on key and web msg", { - let mut wm = keyed("A12", false, None); - wm.key.participant = Some("5511888889999@s.whatsapp.net".into()); - wm.participant = Some("5511888887777@s.whatsapp.net".into()); - wm.message_secret = Some(secret.clone()); + let wm = wa::WebMessageInfo { + key: buffa::MessageField::some(wa::MessageKey { + from_me: Some(false), + id: Some("A12".to_string()), + participant: Some("5511888889999@s.whatsapp.net".into()), + ..Default::default() + }), + participant: Some("5511888887777@s.whatsapp.net".into()), + message_secret: Some(secret.clone()), + message_timestamp: Some(1_700_000_777), + ..Default::default() + }; wrap_in_history_msg(&wm) }); add("empty top-level secret", { @@ -2102,7 +1957,7 @@ mod tests { } .encode_to_vec(); let msg_secret = wa::Message { - message_context_info: Some(Box::new(secret_ctx(&secret))), + message_context_info: buffa::MessageField::some(secret_ctx(&secret)), ..Default::default() } .encode_to_vec(); @@ -2188,10 +2043,10 @@ mod tests { // Repeated occurrences of the same carrier: prost merges their // contextInfo fields; the eager overwrite-when-present walk must agree. let etm_fwd = wa::message::ExtendedTextMessage { - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { is_forwarded: Some(true), ..Default::default() - })), + }), ..Default::default() } .encode_to_vec(); @@ -2232,15 +2087,17 @@ mod tests { // Repeated ephemeral wrapper occurrences: inner messages merge. let eph_poll = wa::Message { - ephemeral_message: Some(fp(wa::Message { - poll_creation_message: Some(Box::new(wa::message::PollCreationMessage::default())), + ephemeral_message: buffa::MessageField::some(fp(wa::Message { + poll_creation_message: buffa::MessageField::some( + wa::message::PollCreationMessage::default(), + ), ..Default::default() })), ..Default::default() } .encode_to_vec(); let eph_text = wa::Message { - ephemeral_message: Some(fp(wa::Message { + ephemeral_message: buffa::MessageField::some(fp(wa::Message { conversation: Some("t".into()), ..Default::default() })), @@ -2295,7 +2152,9 @@ mod tests { // proto recurses per level and overflows the test stack. for depth in [50usize, 120] { let mut msg = wa::Message { - poll_creation_message: Some(Box::new(wa::message::PollCreationMessage::default())), + poll_creation_message: buffa::MessageField::some( + wa::message::PollCreationMessage::default(), + ), ..Default::default() } .encode_to_vec(); @@ -2362,11 +2221,11 @@ mod tests { let total_msgs = 200usize; for i in 0..total_msgs { let wm = wa::WebMessageInfo { - key: wa::MessageKey { + key: buffa::MessageField::some(wa::MessageKey { from_me: Some(false), id: Some(format!("MSG{i:04}")), ..Default::default() - }, + }), // Varied payload so zlib does not flatten the blob to nothing. message_secret: Some(vec![(i % 251) as u8; 32]), ..Default::default() @@ -2419,11 +2278,11 @@ mod tests { }; let web_msg = wa::WebMessageInfo { - key: wa::MessageKey { + key: buffa::MessageField::some(wa::MessageKey { from_me: Some(false), id: Some("EARLY_MSG".into()), ..Default::default() - }, + }), message_secret: Some(vec![0x22u8; 32]), ..Default::default() } @@ -2482,10 +2341,10 @@ mod tests { } .encode_to_vec(); let msg_with_secret = wa::Message { - message_context_info: Some(Box::new(wa::MessageContextInfo { + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(vec![0x11u8; 32]), ..Default::default() - })), + }), ..Default::default() } .encode_to_vec(); @@ -2560,20 +2419,20 @@ mod tests { fn test_empty_secret_still_yields_record() { let chat = "5511777776666@s.whatsapp.net"; let hs = wa::HistorySync { - sync_type: wa::history_sync::HistorySyncType::InitialBootstrap as i32, + sync_type: wa::history_sync::HistorySyncType::INITIAL_BOOTSTRAP, conversations: vec![wa::Conversation { id: chat.to_string(), messages: vec![wa::HistorySyncMsg { - message: Some(Box::new(wa::WebMessageInfo { - key: wa::MessageKey { + message: buffa::MessageField::some(wa::WebMessageInfo { + key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some(chat.to_string()), from_me: Some(false), id: Some("EMPTY_SECRET".to_string()), ..Default::default() - }, + }), message_secret: Some(Vec::new()), ..Default::default() - })), + }), ..Default::default() }], ..Default::default() @@ -2591,7 +2450,7 @@ mod tests { fn test_nct_salt_extracted_from_history_sync() { let salt = vec![0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]; let hs = wa::HistorySync { - sync_type: wa::history_sync::HistorySyncType::InitialBootstrap as i32, + sync_type: wa::history_sync::HistorySyncType::INITIAL_BOOTSTRAP, nct_salt: Some(salt.clone()), ..Default::default() }; @@ -2605,7 +2464,7 @@ mod tests { #[test] fn test_nct_salt_none_when_absent() { let hs = wa::HistorySync { - sync_type: wa::history_sync::HistorySyncType::InitialBootstrap as i32, + sync_type: wa::history_sync::HistorySyncType::INITIAL_BOOTSTRAP, ..Default::default() }; @@ -2619,7 +2478,7 @@ mod tests { fn test_nct_salt_and_pushname_coexist() { let salt = vec![0x01, 0x02, 0x03]; let hs = wa::HistorySync { - sync_type: wa::history_sync::HistorySyncType::InitialBootstrap as i32, + sync_type: wa::history_sync::HistorySyncType::INITIAL_BOOTSTRAP, nct_salt: Some(salt.clone()), pushnames: vec![wa::Pushname { id: Some("0000000000".into()), @@ -2635,6 +2494,16 @@ mod tests { assert_eq!(result.own_pushname.as_deref(), Some("TestUser")); } + #[test] + fn read_varint_rejects_overflowing_tenth_byte() { + let overflowing = [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x02]; + + assert!( + read_varint(&overflowing).is_none(), + "10th byte above 0x01 must fail" + ); + } + #[test] fn test_message_secrets_extracted_from_history_sync() { let chat = "5511777776666@s.whatsapp.net"; @@ -2642,40 +2511,42 @@ mod tests { let top_level_secret = vec![0x44u8; 32]; let context_secret = vec![0x55u8; 32]; let hs = wa::HistorySync { - sync_type: wa::history_sync::HistorySyncType::InitialBootstrap as i32, + sync_type: wa::history_sync::HistorySyncType::INITIAL_BOOTSTRAP, conversations: vec![wa::Conversation { id: chat.to_string(), messages: vec![ wa::HistorySyncMsg { - message: Some(Box::new(wa::WebMessageInfo { - key: wa::MessageKey { + message: buffa::MessageField::some(wa::WebMessageInfo { + key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some(chat.to_string()), from_me: Some(false), id: Some("HIST_TOP_LEVEL".to_string()), participant: Some(participant.to_string()), - }, + }), message_secret: Some(top_level_secret.clone()), ..Default::default() - })), + }), ..Default::default() }, wa::HistorySyncMsg { - message: Some(Box::new(wa::WebMessageInfo { - key: wa::MessageKey { + message: buffa::MessageField::some(wa::WebMessageInfo { + key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some(chat.to_string()), from_me: Some(true), id: Some("HIST_CONTEXT".to_string()), participant: None, - }, - message: Some(Box::new(wa::Message { - message_context_info: Some(Box::new(wa::MessageContextInfo { - message_secret: Some(context_secret.clone()), - ..Default::default() - })), + }), + message: buffa::MessageField::some(wa::Message { + message_context_info: buffa::MessageField::some( + wa::MessageContextInfo { + message_secret: Some(context_secret.clone()), + ..Default::default() + }, + ), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }, ], @@ -2706,93 +2577,152 @@ mod tests { ); } + /// Regression: a single malformed message inside a conversation must NOT + /// discard the conversation's other message secrets or its tctoken. The + /// pre-fix code decoded the whole conversation as one view (all-or-nothing), + /// so one bad message dropped everything. #[test] - fn test_top_level_message_secret_takes_priority_over_context() { - // A message carrying BOTH the top-level WebMessageInfo.message_secret and a - // nested message_context_info.message_secret must extract the top-level one - // (the move-based push_secret_record must `.take()` the right source). + fn malformed_message_does_not_drop_conversation_secrets_or_tctoken() { let chat = "5511777776666@s.whatsapp.net"; - let top_level_secret = vec![0xAAu8; 32]; - let context_secret = vec![0xBBu8; 32]; - let hs = wa::HistorySync { - sync_type: wa::history_sync::HistorySyncType::InitialBootstrap as i32, - conversations: vec![wa::Conversation { - id: chat.to_string(), - messages: vec![wa::HistorySyncMsg { - message: Some(Box::new(wa::WebMessageInfo { - key: wa::MessageKey { - remote_jid: Some(chat.to_string()), - from_me: Some(false), - id: Some("HIST_BOTH".to_string()), - participant: Some("5511888889999@s.whatsapp.net".to_string()), - }, - message_secret: Some(top_level_secret.clone()), - message: Some(Box::new(wa::Message { - message_context_info: Some(Box::new(wa::MessageContextInfo { - message_secret: Some(context_secret.clone()), - ..Default::default() - })), - ..Default::default() - })), - ..Default::default() - })), - ..Default::default() - }], + let secret = vec![0x44u8; 32]; + let tc_token = vec![0x99u8; 16]; + + // Hand-build the conversation bytes: id (1), a valid message (2), a + // CORRUPT message (2) with a length-delimited subfield whose declared + // length runs past its own bytes, then tctoken (21) + ts (22). + fn write_tag(buf: &mut Vec, field: u32, wt: u32) { + let tag = (field << 3) | wt; + let mut v = tag as u64; + loop { + let b = (v & 0x7f) as u8; + v >>= 7; + if v != 0 { + buf.push(b | 0x80); + } else { + buf.push(b); + break; + } + } + } + fn write_len(buf: &mut Vec, mut n: u64) { + loop { + let b = (n & 0x7f) as u8; + n >>= 7; + if n != 0 { + buf.push(b | 0x80); + } else { + buf.push(b); + break; + } + } + } + fn write_ld(buf: &mut Vec, field: u32, payload: &[u8]) { + write_tag(buf, field, 2); + write_len(buf, payload.len() as u64); + buf.extend_from_slice(payload); + } + + let valid_msg = wa::HistorySyncMsg { + message: buffa::MessageField::some(wa::WebMessageInfo { + key: buffa::MessageField::some(wa::MessageKey { + remote_jid: Some(chat.to_string()), + from_me: Some(false), + id: Some("GOOD_MSG".to_string()), + participant: None, + }), + message_secret: Some(secret.clone()), ..Default::default() - }], + }), ..Default::default() + } + .encode_to_vec(); + + // Corrupt message: field 1 (LEN) claims 50 bytes but only 1 follows. + let corrupt_msg = { + let mut m = Vec::new(); + write_tag(&mut m, 1, 2); + write_len(&mut m, 50); + m.push(0x00); + m }; - let compressed = encode_and_compress(&hs); - let result = process_history_sync(compressed, None, false).unwrap(); + let mut conv = Vec::new(); + write_ld(&mut conv, 1, chat.as_bytes()); // id + write_ld(&mut conv, 2, &corrupt_msg); // bad message FIRST (worst case) + write_ld(&mut conv, 2, &valid_msg); // good message after the bad one + write_ld(&mut conv, 21, &tc_token); // tctoken + write_tag(&mut conv, 22, 0); // tctoken timestamp (varint) + write_len(&mut conv, 1_700_000_000); + + // Wrap conv as HistorySync.conversations[0] (field 2). + let mut hs_bytes = Vec::new(); + write_tag(&mut hs_bytes, 1, 0); // sync_type (varint) + // INITIAL_BOOTSTRAP = 0 on the wire + write_len(&mut hs_bytes, 0u64); + write_ld(&mut hs_bytes, 2, &conv); - assert_eq!(result.msg_secret_records.len(), 1); - assert_eq!(result.msg_secret_records[0].msg_id, "HIST_BOTH"); - assert_eq!( - result.msg_secret_records[0].secret.as_slice(), - top_level_secret, - "top-level message_secret must win over the context-info one" - ); - assert_eq!( - result.msg_secret_records[0].key_participant.as_deref(), - Some("5511888889999@s.whatsapp.net") - ); + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(&hs_bytes).unwrap(); + let compressed = encoder.finish().unwrap(); + + // Both paths (full retain + streaming) must keep the good secret + tctoken. + for retain in [true, false] { + let result = process_history_sync(compressed.clone(), None, retain).unwrap(); + assert_eq!( + result.msg_secret_records.len(), + 1, + "good message secret must survive a malformed sibling (retain={retain})" + ); + assert_eq!(result.msg_secret_records[0].msg_id, "GOOD_MSG"); + assert_eq!( + result.msg_secret_records[0].secret.as_slice(), + secret.as_slice() + ); + assert_eq!( + result.tc_token_candidates.len(), + 1, + "tctoken must survive a malformed message (retain={retain})" + ); + assert_eq!(result.tc_token_candidates[0].tc_token, tc_token); + } } #[test] fn test_forwarded_message_secrets_skipped_from_history_sync() { let chat = "5511000000001@s.whatsapp.net"; let hs = wa::HistorySync { - sync_type: wa::history_sync::HistorySyncType::InitialBootstrap as i32, + sync_type: wa::history_sync::HistorySyncType::INITIAL_BOOTSTRAP, conversations: vec![wa::Conversation { id: chat.to_string(), messages: vec![wa::HistorySyncMsg { - message: Some(Box::new(wa::WebMessageInfo { - key: wa::MessageKey { + message: buffa::MessageField::some(wa::WebMessageInfo { + key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some(chat.to_string()), from_me: Some(false), id: Some("HIST_FORWARDED".to_string()), ..Default::default() - }, - message: Some(Box::new(wa::Message { - extended_text_message: Some(Box::new( + }), + message: buffa::MessageField::some(wa::Message { + extended_text_message: buffa::MessageField::some( wa::message::ExtendedTextMessage { text: Some("forwarded".into()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { is_forwarded: Some(true), ..Default::default() - })), + }), ..Default::default() }, - )), - message_context_info: Some(Box::new(wa::MessageContextInfo { - message_secret: Some(vec![0x66u8; 32]), - ..Default::default() - })), + ), + message_context_info: buffa::MessageField::some( + wa::MessageContextInfo { + message_secret: Some(vec![0x66u8; 32]), + ..Default::default() + }, + ), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }], ..Default::default() @@ -2810,50 +2740,58 @@ mod tests { fn test_nested_forwarded_message_secrets_skipped_from_history_sync() { let chat = "5511000000002@s.whatsapp.net"; let hs = wa::HistorySync { - sync_type: wa::history_sync::HistorySyncType::InitialBootstrap as i32, + sync_type: wa::history_sync::HistorySyncType::INITIAL_BOOTSTRAP, conversations: vec![wa::Conversation { id: chat.to_string(), messages: vec![wa::HistorySyncMsg { - message: Some(Box::new(wa::WebMessageInfo { - key: wa::MessageKey { + message: buffa::MessageField::some(wa::WebMessageInfo { + key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some(chat.to_string()), from_me: Some(false), id: Some("HIST_NESTED_FORWARDED".to_string()), ..Default::default() - }, - message: Some(Box::new(wa::Message { - view_once_message: Some(Box::new(wa::message::FutureProofMessage { - message: Some(Box::new(wa::Message { - ephemeral_message: Some(Box::new( - wa::message::FutureProofMessage { - message: Some(Box::new(wa::Message { - extended_text_message: Some(Box::new( - wa::message::ExtendedTextMessage { - text: Some("nested".into()), - context_info: Some(Box::new( - wa::ContextInfo { - is_forwarded: Some(true), + }), + message: buffa::MessageField::some(wa::Message { + view_once_message: buffa::MessageField::some( + wa::message::FutureProofMessage { + message: buffa::MessageField::some(wa::Message { + ephemeral_message: buffa::MessageField::some( + wa::message::FutureProofMessage { + message: buffa::MessageField::some(wa::Message { + extended_text_message: + buffa::MessageField::some( + wa::message::ExtendedTextMessage { + text: Some("nested".into()), + context_info: + buffa::MessageField::some( + wa::ContextInfo { + is_forwarded: Some( + true, + ), + ..Default::default() + }, + ), ..Default::default() }, - )), - ..Default::default() - }, - )), - ..Default::default() - })), - }, - )), + ), + ..Default::default() + }), + }, + ), + ..Default::default() + }), + }, + ), + message_context_info: buffa::MessageField::some( + wa::MessageContextInfo { + message_secret: Some(vec![0x77u8; 32]), ..Default::default() - })), - })), - message_context_info: Some(Box::new(wa::MessageContextInfo { - message_secret: Some(vec![0x77u8; 32]), - ..Default::default() - })), + }, + ), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }], ..Default::default() @@ -2937,7 +2875,6 @@ mod tests { /// Multi-conversation fixture: a >64 KB DM (spans decompress chunks, has a /// tctoken), a group (tctoken must be ignored), pushname and nctSalt. fn parity_fixture(own: &str) -> wa::HistorySync { - use wa::history_sync::HistorySyncType; let dm = "5511777776666@s.whatsapp.net"; let group = "123456789-987654321@g.us"; let participant = "5511888889999@s.whatsapp.net"; @@ -2945,17 +2882,17 @@ mod tests { let mut big_msgs = Vec::new(); for i in 0..1500u32 { big_msgs.push(wa::HistorySyncMsg { - message: Some(Box::new(wa::WebMessageInfo { - key: wa::MessageKey { + message: buffa::MessageField::some(wa::WebMessageInfo { + key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some(dm.to_string()), from_me: Some(i % 2 == 0), id: Some(format!("BIG-{i}")), participant: Some(participant.to_string()), - }, + }), message_timestamp: Some(1_700_000_000 + i as u64), message_secret: Some(vec![(i % 251) as u8; 32]), ..Default::default() - })), + }), msg_order_id: Some(i as u64 + 1), }); } @@ -2967,19 +2904,20 @@ mod tests { ..Default::default() }; + // Group conversation: a secret message, but its tctoken must be ignored. let group_conv = wa::Conversation { id: group.to_string(), messages: vec![wa::HistorySyncMsg { - message: Some(Box::new(wa::WebMessageInfo { - key: wa::MessageKey { + message: buffa::MessageField::some(wa::WebMessageInfo { + key: buffa::MessageField::some(wa::MessageKey { remote_jid: Some(group.to_string()), from_me: Some(false), id: Some("GRP-1".to_string()), participant: Some(participant.to_string()), - }, + }), message_secret: Some(vec![0x33u8; 32]), ..Default::default() - })), + }), msg_order_id: Some(1), }], tc_token: Some(vec![0xCDu8; 16]), @@ -2988,7 +2926,7 @@ mod tests { }; wa::HistorySync { - sync_type: HistorySyncType::InitialBootstrap as i32, + sync_type: wa::history_sync::HistorySyncType::INITIAL_BOOTSTRAP, conversations: vec![big_conv, group_conv], pushnames: vec![wa::Pushname { id: Some(own.to_string()), @@ -3045,9 +2983,9 @@ mod tests { } /// Collecting `next_conversation()` + `remainder()` and stitching them back - /// together must equal one full prost decode of the decompressed blob. + /// together must equal one full decode of the decompressed blob. #[test] - fn stream_parity_with_full_prost_decode() { + fn stream_parity_with_full_decode() { let own = "5511000000000"; let hs = parity_fixture(own); let compressed = encode_and_compress(&hs); @@ -3131,7 +3069,7 @@ mod tests { fn stream_conversationless_blobs() { let cases: Vec = vec![ wa::HistorySync { - sync_type: wa::history_sync::HistorySyncType::PushName as i32, + sync_type: wa::history_sync::HistorySyncType::PUSH_NAME, pushnames: vec![wa::Pushname { id: Some("5511000000000".into()), pushname: Some("Me".into()), @@ -3139,7 +3077,7 @@ mod tests { ..Default::default() }, wa::HistorySync { - sync_type: wa::history_sync::HistorySyncType::InitialBootstrap as i32, + sync_type: wa::history_sync::HistorySyncType::INITIAL_BOOTSTRAP, nct_salt: Some(vec![1, 2, 3]), ..Default::default() }, @@ -3283,17 +3221,17 @@ mod tests { let big = wa::Conversation { id: "5511111111111@s.whatsapp.net".into(), messages: vec![wa::HistorySyncMsg { - message: Some(Box::new(wa::WebMessageInfo { - key: wa::MessageKey { + message: buffa::MessageField::some(wa::WebMessageInfo { + key: buffa::MessageField::some(wa::MessageKey { id: Some("BIG".into()), ..Default::default() - }, - message: Some(Box::new(wa::Message { + }), + message: buffa::MessageField::some(wa::Message { conversation: Some("x".repeat(1_000_000)), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }], ..Default::default() @@ -3319,17 +3257,17 @@ mod tests { conversations.push(wa::Conversation { id: format!("55119{i:08}@s.whatsapp.net"), messages: vec![wa::HistorySyncMsg { - message: Some(Box::new(wa::WebMessageInfo { - key: wa::MessageKey { + message: buffa::MessageField::some(wa::WebMessageInfo { + key: buffa::MessageField::some(wa::MessageKey { id: Some(format!("M{i}")), ..Default::default() - }, - message: Some(Box::new(wa::Message { + }), + message: buffa::MessageField::some(wa::Message { conversation: Some(format!("{i}").repeat(4_000)), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }], ..Default::default() @@ -3356,13 +3294,13 @@ mod tests { conversations: vec![wa::Conversation { id: "5511111111111@s.whatsapp.net".into(), messages: vec![wa::HistorySyncMsg { - message: Some(Box::new(wa::WebMessageInfo { - message: Some(Box::new(wa::Message { + message: buffa::MessageField::some(wa::WebMessageInfo { + message: buffa::MessageField::some(wa::Message { conversation: Some("y".repeat(64 * 1024)), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }], ..Default::default() diff --git a/wacore/src/iq/usync.rs b/wacore/src/iq/usync.rs index fbe5d5bb8..356ba22f4 100644 --- a/wacore/src/iq/usync.rs +++ b/wacore/src/iq/usync.rs @@ -953,19 +953,18 @@ mod tests { /// Build a dummy key-index-list node for device IDs (used in test fixtures) fn build_test_key_index_list_node(device_ids: &[u16]) -> Node { - use prost::Message; + use buffa::Message; let valid_indexes: Vec = device_ids.iter().map(|&id| id as u32).collect(); - let key_index = waproto::whatsapp::AdvKeyIndexList { + let key_index = waproto::whatsapp::ADVKeyIndexList { raw_id: Some(1), timestamp: Some(1000), current_index: Some(valid_indexes.iter().copied().max().unwrap_or(0)), valid_indexes, - account_type: None, + ..Default::default() }; - let signed = waproto::whatsapp::AdvSignedKeyIndexList { + let signed = waproto::whatsapp::ADVSignedKeyIndexList { details: Some(key_index.encode_to_vec()), - account_signature: None, - account_signature_key: None, + ..Default::default() }; NodeBuilder::new("key-index-list") .attr("ts", "1000") diff --git a/wacore/src/media_retry.rs b/wacore/src/media_retry.rs index ccb4ac612..5d5724ce7 100644 --- a/wacore/src/media_retry.rs +++ b/wacore/src/media_retry.rs @@ -11,8 +11,8 @@ //! WAWebHandleMediaRetryNotification. use anyhow::{Result, anyhow}; +use buffa::{Message, MessageView}; use hkdf::Hkdf; -use prost::Message; use rand::Rng; use sha2::Sha256; use wacore_binary::Jid; @@ -81,7 +81,9 @@ pub fn encrypt_media_retry_receipt( Ok((ciphertext, iv)) } -/// Decrypt a `MediaRetryNotification` protobuf from the server response. +/// Decrypt a media-retry notification, returning the plaintext protobuf bytes. +/// Decode them with [`wa::MediaRetryNotificationView`] to read the handful of +/// needed fields without an owned decode. /// /// WA Web: `WAWebCryptoMediaRetry.decryptMediaRetryNotification(mediaKey, stanzaId, iv, ciphertext)` pub fn decrypt_media_retry_notification( @@ -89,7 +91,7 @@ pub fn decrypt_media_retry_notification( stanza_id: &str, iv: &[u8], ciphertext: &[u8], -) -> Result { +) -> Result> { let key = derive_media_retry_key(media_key)?; let nonce: &[u8; 12] = iv.try_into().map_err(|_| anyhow!("Invalid IV length"))?; @@ -103,8 +105,7 @@ pub fn decrypt_media_retry_notification( ) .map_err(|e| anyhow!("AES-GCM decrypt failed: {e}"))?; - wa::MediaRetryNotification::decode(plaintext.as_slice()) - .map_err(|e| anyhow!("protobuf decode failed: {e}")) + Ok(plaintext) } /// Build the `` node for a media retry request. @@ -226,11 +227,13 @@ pub fn parse_media_retry_notification( .and_then(get_bytes_content_ref) .ok_or_else(|| anyhow!("missing enc_iv in encrypt node"))?; - let notification = decrypt_media_retry_notification(media_key, &msg_id, enc_iv, enc_p)?; + let plaintext = decrypt_media_retry_notification(media_key, &msg_id, enc_iv, enc_p)?; + let notification = wa::MediaRetryNotificationView::decode_view(&plaintext) + .map_err(|e| anyhow!("protobuf decode failed: {e}"))?; // Validate stanza ID matches - if let Some(ref returned_id) = notification.stanza_id - && returned_id != &msg_id + if let Some(returned_id) = notification.stanza_id + && returned_id != msg_id.as_str() { return Err(anyhow!( "stanza ID mismatch: expected {msg_id}, got {returned_id}" @@ -238,16 +241,19 @@ pub fn parse_media_retry_notification( } // Check result enum - let result_type = notification.result.unwrap_or(0); - match wa::media_retry_notification::ResultType::try_from(result_type) { - Ok(wa::media_retry_notification::ResultType::Success) => { + let result_type = notification + .result + .unwrap_or(wa::media_retry_notification::ResultType::GENERAL_ERROR); + match result_type { + wa::media_retry_notification::ResultType::SUCCESS => { let direct_path = notification .direct_path - .ok_or_else(|| anyhow!("SUCCESS result but no directPath"))?; + .ok_or_else(|| anyhow!("SUCCESS result but no directPath"))? + .to_string(); Ok(MediaRetryResult::Success { direct_path }) } - Ok(wa::media_retry_notification::ResultType::NotFound) => Ok(MediaRetryResult::NotFound), - Ok(wa::media_retry_notification::ResultType::DecryptionError) => { + wa::media_retry_notification::ResultType::NOT_FOUND => Ok(MediaRetryResult::NotFound), + wa::media_retry_notification::ResultType::DECRYPTION_ERROR => { Ok(MediaRetryResult::DecryptionError) } _ => Ok(MediaRetryResult::GeneralError), @@ -265,10 +271,11 @@ mod tests { let (ciphertext, iv) = encrypt_media_retry_receipt(&media_key, stanza_id).unwrap(); - let notification = + let plaintext = decrypt_media_retry_notification(&media_key, stanza_id, &iv, &ciphertext).unwrap(); + let notification = wa::MediaRetryNotificationView::decode_view(&plaintext).unwrap(); - assert_eq!(notification.stanza_id.as_deref(), Some(stanza_id)); + assert_eq!(notification.stanza_id, Some(stanza_id)); } #[test] diff --git a/wacore/src/message_edit.rs b/wacore/src/message_edit.rs index 120a83fea..e253c7c51 100644 --- a/wacore/src/message_edit.rs +++ b/wacore/src/message_edit.rs @@ -64,8 +64,7 @@ pub fn encrypt_message_edit( message_secret: &[u8], ctx: &MessageEditContext<'_>, ) -> Result<(Vec, [u8; IV_SIZE])> { - let mut plaintext = Vec::new(); - waproto::codec::message_encode_into(inner_message, &mut plaintext); + let plaintext = waproto::codec::message_to_vec(inner_message); encrypt_addon(&plaintext, message_secret, &ctx.as_addon_ctx()) } @@ -179,26 +178,26 @@ pub fn decrypt_message_edit_with_fallback( #[cfg(test)] mod tests { use super::*; - use prost::Message as _; + use buffa::MessageField; use waproto::whatsapp as wa; fn make_inner_edit(new_text: &str) -> wa::Message { wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage { - key: Some(wa::MessageKey { + protocol_message: MessageField::some(wa::message::ProtocolMessage { + key: MessageField::some(wa::MessageKey { remote_jid: Some("g@g.us".to_string()), from_me: Some(true), id: Some("AC1234567890ABCDEF".to_string()), participant: None, }), - r#type: Some(wa::message::protocol_message::Type::MessageEdit as i32), - edited_message: Some(Box::new(wa::Message { + r#type: Some(wa::message::protocol_message::Type::MESSAGE_EDIT), + edited_message: MessageField::some(wa::Message { conversation: Some(new_text.to_string()), ..Default::default() - })), + }), timestamp_ms: Some(1_700_000_000_000), ..Default::default() - })), + }), ..Default::default() } } @@ -217,8 +216,8 @@ mod tests { let decoded = decrypt_message_edit(&enc, &iv, &secret, &ctx).unwrap(); let edited = decoded .protocol_message - .as_ref() - .and_then(|pm| pm.edited_message.as_ref()) + .as_option() + .and_then(|pm| pm.edited_message.as_option()) .expect("inner edited message present"); assert_eq!(edited.conversation.as_deref(), Some("edited text")); } @@ -237,8 +236,8 @@ mod tests { assert_eq!( decoded .protocol_message - .as_ref() - .and_then(|pm| pm.edited_message.as_ref()) + .as_option() + .and_then(|pm| pm.edited_message.as_option()) .and_then(|m| m.conversation.as_deref()), Some("B") ); @@ -289,6 +288,7 @@ mod tests { #[test] fn general_decrypt_roundtrips_non_edit_use_case() { use crate::secret_enc_addon::{AddonContext, ModificationType, encrypt_addon}; + use buffa::Message as _; // A POLL_EDIT envelope: same shape as MESSAGE_EDIT, different use-case. let secret = [0x71u8; 32]; @@ -343,8 +343,8 @@ mod tests { .expect("fallback should rescue"); assert_eq!( m.protocol_message - .as_ref() - .and_then(|pm| pm.edited_message.as_ref()) + .as_option() + .and_then(|pm| pm.edited_message.as_option()) .and_then(|m| m.conversation.as_deref()), Some("hello") ); diff --git a/wacore/src/message_processing.rs b/wacore/src/message_processing.rs index 45c3a1fb8..5d38dc355 100644 --- a/wacore/src/message_processing.rs +++ b/wacore/src/message_processing.rs @@ -266,25 +266,29 @@ pub fn process_decrypted_plaintext( let original_msg = crate::messages::decode_plaintext(padded_plaintext, padding_version)?; // Validate DSM presence against sender identity - let has_invalid_dsm = original_msg.device_sent_message.is_some() && !is_from_me; + let has_invalid_dsm = original_msg.device_sent_message.is_set() && !is_from_me; // Unwrap DeviceSentMessage wrapper let mut msg = crate::messages::unwrap_device_sent(original_msg); // Extract SKDM - let skdm = msg.sender_key_distribution_message.clone(); + let skdm = msg.sender_key_distribution_message.as_option().cloned(); // Check if SKDM-only let is_skdm_only = crate::messages::is_sender_key_distribution_only(&mut msg); // Extract protocol message info - let protocol_message = msg.protocol_message.as_ref().map(|pm| ProtocolMessageInfo { - history_sync_notification: pm.history_sync_notification.clone(), - app_state_sync_key_share: pm.app_state_sync_key_share.clone(), - peer_data_operation_request_response: pm - .peer_data_operation_request_response_message - .clone(), - }); + let protocol_message = msg + .protocol_message + .as_option() + .map(|pm| ProtocolMessageInfo { + history_sync_notification: pm.history_sync_notification.as_option().cloned(), + app_state_sync_key_share: pm.app_state_sync_key_share.as_option().cloned(), + peer_data_operation_request_response: pm + .peer_data_operation_request_response_message + .as_option() + .cloned(), + }); Ok(DecryptedMessageResult { message: msg, @@ -516,7 +520,7 @@ mod tests { #[test] fn test_process_decrypted_plaintext_simple() { - use prost::Message as ProtoMessage; + use buffa::Message as ProtoMessage; // Create a simple text message let msg = wa::Message { @@ -536,14 +540,16 @@ mod tests { #[test] fn test_process_decrypted_plaintext_with_skdm() { - use prost::Message as ProtoMessage; + use buffa::Message as ProtoMessage; let msg = wa::Message { conversation: Some("hello".to_string()), - sender_key_distribution_message: Some(wa::message::SenderKeyDistributionMessage { - group_id: Some("group@g.us".to_string()), - axolotl_sender_key_distribution_message: Some(vec![1, 2, 3]), - }), + sender_key_distribution_message: buffa::MessageField::some( + wa::message::SenderKeyDistributionMessage { + group_id: Some("group@g.us".to_string()), + axolotl_sender_key_distribution_message: Some(vec![1, 2, 3]), + }, + ), ..Default::default() }; let plaintext = msg.encode_to_vec(); @@ -556,13 +562,15 @@ mod tests { #[test] fn test_process_decrypted_plaintext_skdm_only() { - use prost::Message as ProtoMessage; + use buffa::Message as ProtoMessage; let msg = wa::Message { - sender_key_distribution_message: Some(wa::message::SenderKeyDistributionMessage { - group_id: Some("group@g.us".to_string()), - axolotl_sender_key_distribution_message: Some(vec![1, 2, 3]), - }), + sender_key_distribution_message: buffa::MessageField::some( + wa::message::SenderKeyDistributionMessage { + group_id: Some("group@g.us".to_string()), + axolotl_sender_key_distribution_message: Some(vec![1, 2, 3]), + }, + ), ..Default::default() }; let plaintext = msg.encode_to_vec(); @@ -575,16 +583,16 @@ mod tests { #[test] fn test_process_decrypted_plaintext_invalid_dsm() { - use prost::Message as ProtoMessage; + use buffa::Message as ProtoMessage; let msg = wa::Message { - device_sent_message: Some(Box::new(wa::message::DeviceSentMessage { - message: Some(Box::new(wa::Message { + device_sent_message: buffa::MessageField::some(wa::message::DeviceSentMessage { + message: buffa::MessageField::some(wa::Message { conversation: Some("inner".to_string()), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }; let plaintext = msg.encode_to_vec(); @@ -599,16 +607,16 @@ mod tests { #[test] fn test_process_decrypted_plaintext_valid_dsm() { - use prost::Message as ProtoMessage; + use buffa::Message as ProtoMessage; let msg = wa::Message { - device_sent_message: Some(Box::new(wa::message::DeviceSentMessage { - message: Some(Box::new(wa::Message { + device_sent_message: buffa::MessageField::some(wa::message::DeviceSentMessage { + message: buffa::MessageField::some(wa::Message { conversation: Some("self-sent".to_string()), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }; let plaintext = msg.encode_to_vec(); diff --git a/wacore/src/messages.rs b/wacore/src/messages.rs index 8f8b3f401..7d42b4c73 100644 --- a/wacore/src/messages.rs +++ b/wacore/src/messages.rs @@ -1,8 +1,11 @@ use crate::libsignal::crypto::CryptographicHash; use anyhow::{Result, anyhow}; use base64::Engine as _; +use buffa::MessageView; +// Encode/decode of proto trees is routed through `waproto::codec` so the tree is +// instantiated once in waproto; tests still call the trait methods directly. #[cfg(test)] -use prost::Message as _; +use buffa::Message as _; use waproto::whatsapp as wa; pub struct MessageUtils; @@ -23,10 +26,17 @@ impl MessageUtils { } /// Encode + pad in a single pre-sized allocation. + /// + /// Runs ONE `compute_size` pass over the message tree and reuses its + /// `SizeCache` for the write. The previous `encoded_len()` + `encode()` + /// ran `compute_size` twice (once to size the buffer, once inside `encode`) + /// over the whole tree on this per-recipient send hot path. pub fn encode_and_pad(msg: &wa::Message) -> Vec { let pad = Self::random_pad_len(); - let mut buf = Vec::with_capacity(waproto::codec::message_encoded_len(msg) + pad as usize); - waproto::codec::message_encode_into(msg, &mut buf); + let mut cache = buffa::SizeCache::new(); + let size = waproto::codec::message_compute_size(msg, &mut cache); + let mut buf = Vec::with_capacity(size + pad as usize); + waproto::codec::message_write_to(msg, &mut cache, &mut buf); buf.resize(buf.len() + pad as usize, pad); buf } @@ -47,17 +57,17 @@ impl MessageUtils { extra_context: Option<&wa::MessageContextInfo>, ) -> Vec { let pad = Self::random_pad_len(); - let extra_len = extra_context.map_or(0, |c| { - len_delimited_len( - TAG_MESSAGE_CONTEXT_INFO, - waproto::codec::message_context_info_encoded_len(c), - ) - }); - let mut buf = - Vec::with_capacity(waproto::codec::message_encoded_len(msg) + extra_len + pad as usize); - waproto::codec::message_encode_into(msg, &mut buf); - if let Some(c) = extra_context { - push_message_field(TAG_MESSAGE_CONTEXT_INFO, c, &mut buf); + // Size the extra mci once; the same cache feeds the write below. + let mut c_cache = buffa::SizeCache::new(); + let extra_inner = extra_context + .map(|c| waproto::codec::message_context_info_compute_size(c, &mut c_cache)); + let extra_len = extra_inner.map_or(0, |sz| len_delimited_len(TAG_MESSAGE_CONTEXT_INFO, sz)); + let mut msg_cache = buffa::SizeCache::new(); + let msg_size = waproto::codec::message_compute_size(msg, &mut msg_cache); + let mut buf = Vec::with_capacity(msg_size + extra_len + pad as usize); + waproto::codec::message_write_to(msg, &mut msg_cache, &mut buf); + if let (Some(c), Some(sz)) = (extra_context, extra_inner) { + push_message_field_sized(TAG_MESSAGE_CONTEXT_INFO, c, sz, &mut c_cache, &mut buf); } buf.resize(buf.len() + pad as usize, pad); buf @@ -74,16 +84,14 @@ impl MessageUtils { extra_context: Option<&wa::MessageContextInfo>, ) -> Vec { let pad = Self::random_pad_len(); - let extra_len = extra_context.map_or(0, |c| { - len_delimited_len( - TAG_MESSAGE_CONTEXT_INFO, - waproto::codec::message_context_info_encoded_len(c), - ) - }); + let mut c_cache = buffa::SizeCache::new(); + let extra_inner = extra_context + .map(|c| waproto::codec::message_context_info_compute_size(c, &mut c_cache)); + let extra_len = extra_inner.map_or(0, |sz| len_delimited_len(TAG_MESSAGE_CONTEXT_INFO, sz)); let mut buf = Vec::with_capacity(content.len() + extra_len + pad as usize); buf.extend_from_slice(content); - if let Some(c) = extra_context { - push_message_field(TAG_MESSAGE_CONTEXT_INFO, c, &mut buf); + if let (Some(c), Some(sz)) = (extra_context, extra_inner) { + push_message_field_sized(TAG_MESSAGE_CONTEXT_INFO, c, sz, &mut c_cache, &mut buf); } buf.resize(buf.len() + pad as usize, pad); buf @@ -116,7 +124,7 @@ impl MessageUtils { extra_context: Option<&wa::MessageContextInfo>, destination_jid: &str, ) -> DmPlaintexts { - if message.message_context_info.is_some() { + if message.message_context_info.is_set() { let mut owned = message.clone(); if let Some(extra) = extra_context { // Fold the reporting context into the existing mci via the same merge the @@ -124,7 +132,8 @@ impl MessageUtils { // prepare_message_with_context without enumerating its fields here. let ctx = owned .message_context_info - .get_or_insert_with(Default::default); + .as_option_mut() + .expect("mci is set"); waproto::codec::message_context_info_merge( ctx, &waproto::codec::message_context_info_to_vec(extra), @@ -141,19 +150,21 @@ impl MessageUtils { const MAX_PAD: usize = 16; let mci_field_len = extra_context.map_or(0, |m| { + let mut c = buffa::SizeCache::new(); len_delimited_len( TAG_MESSAGE_CONTEXT_INFO, - waproto::codec::message_context_info_encoded_len(m), + waproto::codec::message_context_info_compute_size(m, &mut c), ) }); - let content_len = waproto::codec::message_encoded_len(message); + let mut msg_cache = buffa::SizeCache::new(); + let content_len = waproto::codec::message_compute_size(message, &mut msg_cache); let dest = destination_jid.as_bytes(); // recipient = content (encoded once) + the extra message_context_info field. // Pre-size for content + the appended mci field + padding so it never // reallocates; the content bytes are then spliced into the own-device buffer. let mut recipient = Vec::with_capacity(content_len + mci_field_len + MAX_PAD); - waproto::codec::message_encode_into(message, &mut recipient); + waproto::codec::message_write_to(message, &mut msg_cache, &mut recipient); // own-device plaintext = Message { device_sent_message { destination_jid, // message }, [message_context_info] }. The DeviceSentMessage length is @@ -196,9 +207,10 @@ impl MessageUtils { const MAX_PAD: usize = 16; let mci_field_len = extra_context.map_or(0, |m| { + let mut c = buffa::SizeCache::new(); len_delimited_len( TAG_MESSAGE_CONTEXT_INFO, - waproto::codec::message_context_info_encoded_len(m), + waproto::codec::message_context_info_compute_size(m, &mut c), ) }); let content_len = content.len(); @@ -241,16 +253,18 @@ impl MessageUtils { // mci struct (not a temp Vec): it is small and encoded straight into each buffer. let mci = message.message_context_info.take(); let mci_field_len = mci.as_ref().map_or(0, |m| { + let mut c = buffa::SizeCache::new(); len_delimited_len( TAG_MESSAGE_CONTEXT_INFO, - waproto::codec::message_context_info_encoded_len(m), + waproto::codec::message_context_info_compute_size(m, &mut c), ) }); - let content_len = waproto::codec::message_encoded_len(&message); + let mut msg_cache = buffa::SizeCache::new(); + let content_len = waproto::codec::message_compute_size(&message, &mut msg_cache); let dest = destination_jid.as_bytes(); let mut recipient = Vec::with_capacity(content_len + mci_field_len + MAX_PAD); - waproto::codec::message_encode_into(&message, &mut recipient); + waproto::codec::message_write_to(&message, &mut msg_cache, &mut recipient); let dsm_len = len_delimited_len(TAG_DSM_DESTINATION_JID, dest.len()) + len_delimited_len(TAG_DSM_MESSAGE, content_len); @@ -322,9 +336,9 @@ impl MessageUtils { Self::participant_list_hash(participants).is_ok_and(|computed| computed == expected) } - pub fn unpad_message_ref(plaintext: &[u8], version: u8) -> Result<&[u8]> { + pub fn unpadded_message_len(plaintext: &[u8], version: u8) -> Result { if version == 3 { - return Ok(plaintext); + return Ok(plaintext.len()); } if plaintext.is_empty() { return Err(anyhow::anyhow!("plaintext is empty, cannot unpad")); @@ -339,7 +353,12 @@ impl MessageUtils { return Err(anyhow::anyhow!("invalid padding bytes")); } } - Ok(data) + Ok(data.len()) + } + + pub fn unpad_message_ref(plaintext: &[u8], version: u8) -> Result<&[u8]> { + let unpadded_len = Self::unpadded_message_len(plaintext, version)?; + Ok(&plaintext[..unpadded_len]) } } @@ -350,10 +369,84 @@ impl MessageUtils { /// runtime-independent portion of `handle_decrypted_plaintext`. pub fn decode_plaintext(padded_plaintext: &[u8], padding_version: u8) -> Result { let plaintext_slice = MessageUtils::unpad_message_ref(padded_plaintext, padding_version)?; + // Route through the pinned codec entry point so the Message decode tree + // (BotMetadata/ProtocolMessage/ContextInfo merge_field, etc.) is + // instantiated once in waproto instead of copied into every calling crate. waproto::codec::message_decode(plaintext_slice) .map_err(|e| anyhow::anyhow!("Failed to decode decrypted plaintext: {e}")) } +/// Use when borrowed fields are enough and a full owned message is avoidable. +pub fn decode_plaintext_view( + padded_plaintext: &[u8], + padding_version: u8, +) -> Result> { + let plaintext_slice = MessageUtils::unpad_message_ref(padded_plaintext, padding_version)?; + wa::MessageView::decode_view(plaintext_slice) + .map_err(|e| anyhow::anyhow!("Failed to decode decrypted plaintext: {e}")) +} + +/// Decode a plaintext buffer into a self-contained Buffa view. +pub fn decode_plaintext_owned_view( + padded_plaintext: Vec, + padding_version: u8, +) -> Result { + let unpadded_len = MessageUtils::unpadded_message_len(&padded_plaintext, padding_version)?; + let plaintext = buffa::bytes::Bytes::from(padded_plaintext).slice(0..unpadded_len); + wa::MessageOwnedView::decode(plaintext) + .map_err(|e| anyhow::anyhow!("Failed to decode decrypted plaintext: {e}")) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SenderKeyDistributionOnlyPlaintext<'a> { + pub axolotl_sender_key_distribution_message: Option<&'a [u8]>, +} + +/// Conservative fast path for SKDM-only plaintexts before owned dispatch decode. +pub fn sender_key_distribution_only_plaintext( + padded_plaintext: &[u8], + padding_version: u8, +) -> Result>> { + let plaintext_slice = MessageUtils::unpad_message_ref(padded_plaintext, padding_version)?; + if !has_only_sender_key_distribution_top_level_fields(plaintext_slice)? { + return Ok(None); + } + + let view = wa::MessageView::decode_view(plaintext_slice) + .map_err(|e| anyhow::anyhow!("Failed to decode decrypted plaintext: {e}"))?; + let axolotl_sender_key_distribution_message = view + .sender_key_distribution_message + .as_option() + .and_then(|skdm| skdm.axolotl_sender_key_distribution_message); + + Ok(Some(SenderKeyDistributionOnlyPlaintext { + axolotl_sender_key_distribution_message, + })) +} + +pub fn has_only_sender_key_distribution_top_level_fields( + encoded: &[u8], +) -> Result { + // Generated tag constants, so a proto renumber updates the classifier + // instead of silently misrouting SKDM-only messages. + use waproto::tags::message as m; + let mut cur = encoded; + let mut has_sender_key_distribution = false; + while !cur.is_empty() { + let tag = buffa::encoding::Tag::decode(&mut cur)?; + match tag.field_number() { + m::SENDER_KEY_DISTRIBUTION_MESSAGE + | m::FAST_RATCHET_KEY_SENDER_KEY_DISTRIBUTION_MESSAGE => { + has_sender_key_distribution = true + } + m::MESSAGE_CONTEXT_INFO => {} + _ => return Ok(false), + } + buffa::encoding::skip_field_depth(tag, &mut cur, buffa::RECURSION_LIMIT)?; + } + Ok(has_sender_key_distribution) +} + /// The two padded plaintexts a DM send needs, built from a single encode of the /// shared message content. See [`MessageUtils::encode_dm_plaintexts`]. pub struct DmPlaintexts { @@ -366,8 +459,7 @@ pub struct DmPlaintexts { // Protobuf field numbers spliced by `encode_dm_plaintexts`, sourced from the // generated schema tags so a .proto renumber breaks here at compile time -// instead of silently changing the wire payload. The `splice_*` differential -// tests still pin the hand-written framing itself against prost. +// instead of silently changing the wire payload. const TAG_DEVICE_SENT_MESSAGE: u64 = waproto::tags::message::DEVICE_SENT_MESSAGE as u64; const TAG_MESSAGE_CONTEXT_INFO: u64 = waproto::tags::message::MESSAGE_CONTEXT_INFO as u64; const TAG_DSM_DESTINATION_JID: u64 = @@ -413,31 +505,46 @@ fn len_delimited_len(field: u64, payload_len: usize) -> usize { varint_len((field << 3) | 2) + varint_len(payload_len as u64) + payload_len } -/// Append a prost message as a nested length-delimited field, encoding it +/// Append a message_context_info as a nested length-delimited field, encoding it /// straight into `out` (no intermediate `Vec`). Used for the small /// `message_context_info` field on both plaintexts. #[inline] fn push_message_field(field: u64, msg: &wa::MessageContextInfo, out: &mut Vec) { + let mut cache = buffa::SizeCache::new(); + let size = waproto::codec::message_context_info_compute_size(msg, &mut cache); + push_message_field_sized(field, msg, size, &mut cache, out); +} + +/// Same as [`push_message_field`] but reuses a `SizeCache` the caller already +/// filled by `message_context_info_compute_size` (e.g. for a buffer capacity +/// estimate). `cache` must hold exactly that message's sizes with the cursor at +/// 0; `write_to` consumes them, so this avoids measuring the sub-tree twice. +#[inline] +fn push_message_field_sized( + field: u64, + msg: &wa::MessageContextInfo, + size: usize, + cache: &mut buffa::SizeCache, + out: &mut Vec, +) { push_varint((field << 3) | 2, out); - push_varint( - waproto::codec::message_context_info_encoded_len(msg) as u64, - out, - ); - waproto::codec::message_context_info_encode_into(msg, out); + push_varint(size as u64, out); + waproto::codec::message_context_info_write_to(msg, cache, out); } /// Wrap a message into a DeviceSentMessage for own-device sync, hoisting /// `message_context_info` onto the outer message (matching WA Web). Inverse of /// [`unwrap_device_sent`]. pub fn wrap_device_sent(mut message: wa::Message, destination_jid: String) -> wa::Message { - let context = message.message_context_info.take(); + let context = std::mem::take(&mut message.message_context_info); wa::Message { message_context_info: context, - device_sent_message: Some(Box::new(wa::message::DeviceSentMessage { + device_sent_message: wa::message::DeviceSentMessage { destination_jid: Some(destination_jid), - message: Some(Box::new(message)), - phash: None, - })), + message: message.into(), + ..Default::default() + } + .into(), ..Default::default() } } @@ -454,11 +561,13 @@ pub fn unwrap_device_sent(mut msg: wa::Message) -> wa::Message { if let Some(mut inner) = dsm.message.take() { inner.message_context_info = crate::proto_helpers::merge_dsm_context( inner.message_context_info.take(), - msg.message_context_info.as_deref(), - ); - return *inner; + msg.message_context_info.as_option(), + ) + .map(buffa::MessageField::some) + .unwrap_or_default(); + return inner; } - msg.device_sent_message = Some(dsm); + msg.device_sent_message = buffa::MessageField::some(dsm); } msg } @@ -470,23 +579,27 @@ pub fn unwrap_device_sent(mut msg: wa::Message) -> wa::Message { /// `pkmsg` enc node. We must process it (store the sender key) but should /// not surface it as a user event. pub fn is_sender_key_distribution_only(msg: &mut wa::Message) -> bool { - if msg.sender_key_distribution_message.is_none() + if msg.sender_key_distribution_message.is_unset() && msg .fast_ratchet_key_sender_key_distribution_message - .is_none() + .is_unset() { return false; } // Fast path: most common user-visible fields (avoids the slow path for the typical case). if msg.conversation.is_some() - || msg.extended_text_message.is_some() - || msg.image_message.is_some() - || msg.video_message.is_some() - || msg.audio_message.is_some() - || msg.document_message.is_some() - || msg.reaction_message.is_some() - || msg.protocol_message.is_some() + || msg.extended_text_message.is_set() + || msg.image_message.is_set() + || msg.video_message.is_set() + || msg.audio_message.is_set() + || msg.document_message.is_set() + || msg.reaction_message.is_set() + || msg.protocol_message.is_set() + || msg.sticker_message.is_set() + || msg.contact_message.is_set() + || msg.location_message.is_set() + || msg.live_location_message.is_set() { return false; } @@ -498,13 +611,15 @@ pub fn is_sender_key_distribution_only(msg: &mut wa::Message) -> bool { let fast = msg.fast_ratchet_key_sender_key_distribution_message.take(); let ctx = msg.message_context_info.take(); - // Same predicate as `== Message::default()` (proto2 fields only encode - // when set), without anchoring prost's derived PartialEq tree. - let only = waproto::codec::message_encoded_len(msg) == 0; + // proto fields only encode when non-default, so encoded length 0 means all + // remaining fields are at default — i.e. the message has no user content. + let mut cache = buffa::SizeCache::new(); + let only = waproto::codec::message_compute_size(msg, &mut cache) == 0; - msg.sender_key_distribution_message = skdm; - msg.fast_ratchet_key_sender_key_distribution_message = fast; - msg.message_context_info = ctx; + msg.sender_key_distribution_message = skdm.map(buffa::MessageField::some).unwrap_or_default(); + msg.fast_ratchet_key_sender_key_distribution_message = + fast.map(buffa::MessageField::some).unwrap_or_default(); + msg.message_context_info = ctx.map(buffa::MessageField::some).unwrap_or_default(); only } @@ -737,6 +852,101 @@ pub fn parse_message_info( }) } +#[cfg(test)] +mod plaintext_view_tests { + use super::*; + + fn padded(msg: &wa::Message) -> Vec { + MessageUtils::pad_message_v2(msg.encode_to_vec()) + } + + fn skdm(bytes: &[u8]) -> wa::message::SenderKeyDistributionMessage { + wa::message::SenderKeyDistributionMessage { + group_id: Some("120000000000000000@g.us".to_string()), + axolotl_sender_key_distribution_message: Some(bytes.to_vec()), + } + } + + #[test] + fn decode_plaintext_view_borrows_message_fields() { + let msg = wa::Message { + conversation: Some("hello".to_string()), + ..Default::default() + }; + let padded = padded(&msg); + + let view = decode_plaintext_view(&padded, 2).expect("view decode should succeed"); + + assert_eq!(view.conversation, Some("hello")); + } + + #[test] + fn decode_plaintext_owned_view_keeps_unpadded_bytes() { + let msg = wa::Message { + conversation: Some("hello".to_string()), + ..Default::default() + }; + let padded = padded(&msg); + let padded_len = padded.len(); + + let view = + decode_plaintext_owned_view(padded, 2).expect("owned view decode should succeed"); + + assert_eq!(view.conversation(), Some("hello")); + assert!(view.bytes().len() < padded_len); + } + + #[test] + fn sender_key_distribution_only_plaintext_returns_borrowed_axolotl() { + let msg = wa::Message { + sender_key_distribution_message: buffa::MessageField::some(skdm(&[1, 2, 3])), + ..Default::default() + }; + let padded = padded(&msg); + + let found = sender_key_distribution_only_plaintext(&padded, 2) + .expect("view decode should succeed") + .expect("SKDM-only plaintext should be detected"); + + assert_eq!( + found.axolotl_sender_key_distribution_message, + Some(&[1, 2, 3][..]) + ); + } + + #[test] + fn sender_key_distribution_only_plaintext_rejects_user_content() { + let msg = wa::Message { + conversation: Some("hello".to_string()), + sender_key_distribution_message: buffa::MessageField::some(skdm(&[1, 2, 3])), + ..Default::default() + }; + let padded = padded(&msg); + + let found = + sender_key_distribution_only_plaintext(&padded, 2).expect("view scan should succeed"); + + assert!(found.is_none()); + } + + #[test] + fn sender_key_distribution_only_plaintext_allows_fast_ratchet_only() { + let msg = wa::Message { + fast_ratchet_key_sender_key_distribution_message: buffa::MessageField::some(skdm(&[ + 4, 5, 6, + ])), + ..Default::default() + }; + let padded = padded(&msg); + + let found = sender_key_distribution_only_plaintext(&padded, 2) + .expect("view decode should succeed") + .expect("fast-ratchet SKDM-only plaintext should be detected"); + + assert_eq!(found.axolotl_sender_key_distribution_message, None); + } +} + #[cfg(test)] mod parse_message_info_tests { use super::*; @@ -1151,10 +1361,11 @@ mod device_sent_tests { fn msg_with_secret(secret: &[u8]) -> wa::Message { wa::Message { conversation: Some("hi".into()), - message_context_info: Some(Box::new(wa::MessageContextInfo { + message_context_info: wa::MessageContextInfo { message_secret: Some(secret.to_vec()), ..Default::default() - })), + } + .into(), ..Default::default() } } @@ -1165,17 +1376,23 @@ mod device_sent_tests { let wrapped = wrap_device_sent(msg_with_secret(&secret), "1@s.whatsapp.net".into()); let bytes = wrapped.encode_to_vec(); - let decoded = wa::Message::decode(bytes.as_slice()).unwrap(); + let decoded = wa::Message::decode_from_slice(bytes.as_slice()).unwrap(); assert_eq!( decoded .message_context_info - .and_then(|c| c.message_secret) - .as_deref(), + .as_option() + .and_then(|c| c.message_secret.as_deref()), Some(secret.as_slice()) ); - let inner = decoded.device_sent_message.unwrap().message.unwrap(); - assert!(inner.message_context_info.is_none()); + let inner = decoded + .device_sent_message + .as_option() + .unwrap() + .message + .as_option() + .unwrap(); + assert!(inner.message_context_info.is_unset()); assert_eq!(inner.conversation.as_deref(), Some("hi")); } @@ -1187,25 +1404,33 @@ mod device_sent_tests { }; let wrapped = wrap_device_sent(inner, "1@s.whatsapp.net".into()); - assert!(wrapped.message_context_info.is_none()); - let dsm = wrapped.device_sent_message.unwrap(); + assert!(wrapped.message_context_info.is_unset()); + let dsm = wrapped.device_sent_message.as_option().unwrap(); assert_eq!(dsm.destination_jid.as_deref(), Some("1@s.whatsapp.net")); - assert!(dsm.message.unwrap().message_context_info.is_none()); + assert!( + dsm.message + .as_option() + .unwrap() + .message_context_info + .is_unset() + ); } #[test] fn wrap_then_unwrap_preserves_non_secret_context_fields() { let inner = wa::Message { - message_context_info: Some(Box::new(wa::MessageContextInfo { + message_context_info: wa::MessageContextInfo { message_add_on_duration_in_secs: Some(604800), ..Default::default() - })), + } + .into(), ..Default::default() }; let unwrapped = unwrap_device_sent(wrap_device_sent(inner, "1@s.whatsapp.net".into())); assert_eq!( unwrapped .message_context_info + .as_option() .and_then(|c| c.message_add_on_duration_in_secs), Some(604800) ); @@ -1221,15 +1446,15 @@ mod device_sent_tests { assert_eq!( unwrapped .message_context_info - .and_then(|c| c.message_secret) - .as_deref(), + .as_option() + .and_then(|c| c.message_secret.as_deref()), Some(secret.as_slice()) ); } - // Unpad (v2) + prost-decode a padded plaintext. + // Unpad (v2) + buffa-decode a padded plaintext. fn decode_padded(b: &[u8]) -> wa::Message { - wa::Message::decode(MessageUtils::unpad_message_ref(b, 2).unwrap()).unwrap() + wa::Message::decode_from_slice(MessageUtils::unpad_message_ref(b, 2).unwrap()).unwrap() } /// The spliced plaintexts must decode to exactly what the prost-based path @@ -1285,18 +1510,21 @@ mod device_sent_tests { // extended text + nested context_info (forwarded) AND top-level mci assert_splice_matches( wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: wa::message::ExtendedTextMessage { text: Some("quoted".into()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: wa::ContextInfo { is_forwarded: Some(true), ..Default::default() - })), + } + .into(), ..Default::default() - })), - message_context_info: Some(Box::new(wa::MessageContextInfo { + } + .into(), + message_context_info: wa::MessageContextInfo { message_secret: Some(vec![1, 2, 3, 4]), ..Default::default() - })), + } + .into(), ..Default::default() }, dest, @@ -1304,13 +1532,14 @@ mod device_sent_tests { // media message (refs/keys), no mci assert_splice_matches( wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage { + image_message: wa::message::ImageMessage { url: Some("https://mmg.example/abc".into()), media_key: Some(vec![9u8; 32]), file_sha256: Some(vec![8u8; 32]), mimetype: Some("image/jpeg".into()), ..Default::default() - })), + } + .into(), ..Default::default() }, dest, @@ -1320,10 +1549,11 @@ mod device_sent_tests { // mci-only (no content body) assert_splice_matches( wa::Message { - message_context_info: Some(Box::new(wa::MessageContextInfo { + message_context_info: wa::MessageContextInfo { message_secret: Some(vec![7u8; 32]), ..Default::default() - })), + } + .into(), ..Default::default() }, dest, @@ -1357,7 +1587,7 @@ mod device_sent_tests { } let outer_dsm = wa::Message { - device_sent_message: Some(Box::new(wa::message::DeviceSentMessage::default())), + device_sent_message: wa::message::DeviceSentMessage::default().into(), ..Default::default() }; assert_eq!( @@ -1367,7 +1597,7 @@ mod device_sent_tests { ); let outer_mci = wa::Message { - message_context_info: Some(Box::default()), + message_context_info: wa::MessageContextInfo::default().into(), ..Default::default() }; assert_eq!( @@ -1387,7 +1617,7 @@ mod device_sent_tests { ); let dsm_msg = wa::message::DeviceSentMessage { - message: Some(Box::new(wa::Message::default())), + message: wa::Message::default().into(), ..Default::default() }; assert_eq!( @@ -1408,13 +1638,14 @@ mod device_sent_tests { }, wa::Message { conversation: Some("poll".into()), - message_context_info: Some(Box::new(wa::MessageContextInfo { + message_context_info: wa::MessageContextInfo { // preserved by the merge message_add_on_duration_in_secs: Some(604800), // overwritten by the reporting context message_secret: Some(vec![1u8; 32]), ..Default::default() - })), + } + .into(), ..Default::default() }, ] @@ -1485,18 +1716,19 @@ mod device_sent_tests { ..Default::default() }, wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage { + image_message: wa::message::ImageMessage { url: Some("https://mmg.example/abc".into()), media_key: Some(vec![9u8; 32]), ..Default::default() - })), + } + .into(), ..Default::default() }, ]; for message in shapes { assert!( - message.message_context_info.is_none(), + message.message_context_info.is_unset(), "fast path only applies to messages without a top-level mci" ); for extra in [None, Some(&reporting_ctx)] { @@ -1534,21 +1766,22 @@ mod device_sent_tests { ..Default::default() }, wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage { + image_message: wa::message::ImageMessage { url: Some("https://mmg.example/abc".into()), media_key: Some(vec![9u8; 32]), ..Default::default() - })), + } + .into(), ..Default::default() }, ]; for message in shapes { assert!( - message.message_context_info.is_none(), + message.message_context_info.is_unset(), "the _from_encoded path only applies to messages without a top-level mci" ); - let content = waproto::codec::message_to_vec(&message); + let content = message.encode_to_vec(); for extra in [None, Some(&reporting_ctx)] { assert_eq!( unpad(&MessageUtils::pad_with_context_from_encoded( diff --git a/wacore/src/msg_secret.rs b/wacore/src/msg_secret.rs index f4a1289fd..e95c5f52e 100644 --- a/wacore/src/msg_secret.rs +++ b/wacore/src/msg_secret.rs @@ -128,8 +128,8 @@ pub fn is_bot_context(chat_is_bot: bool, msg: &wa::Message) -> bool { fn invokes_bot(msg: &wa::Message) -> bool { let has_bot_metadata = |m: &wa::Message| { m.message_context_info - .as_ref() - .is_some_and(|c| c.bot_metadata.is_some()) + .as_option() + .is_some_and(|c| c.bot_metadata.as_option().is_some()) }; // botMetadata sits on the top-level MessageContextInfo even when wrapped. has_bot_metadata(msg) || has_bot_metadata(msg.get_base_message()) || msg.mentions_any_bot() @@ -137,10 +137,10 @@ fn invokes_bot(msg: &wa::Message) -> bool { fn message_is_poll_or_event(msg: &wa::Message) -> bool { let base = msg.get_base_message(); - base.poll_creation_message.is_some() - || base.poll_creation_message_v2.is_some() - || base.poll_creation_message_v3.is_some() - || base.event_message.is_some() + base.poll_creation_message.as_option().is_some() + || base.poll_creation_message_v2.as_option().is_some() + || base.poll_creation_message_v3.as_option().is_some() + || base.event_message.as_option().is_some() } /// Classify a message for retention. Bot context wins (bot horizon), then @@ -347,10 +347,10 @@ mod tests { assert!(is_bot_context(true, &wa::Message::default())); // bot_metadata on a non-bot (e.g. group) chat is still a bot context. let prompt = wa::Message { - message_context_info: Some(Box::new(wa::MessageContextInfo { - bot_metadata: Some(wa::BotMetadata::default()), + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { + bot_metadata: buffa::MessageField::some(wa::BotMetadata::default()), ..Default::default() - })), + }), ..Default::default() }; assert!(is_bot_context(false, &prompt)); diff --git a/wacore/src/pair.rs b/wacore/src/pair.rs index f9f26eb4b..bbb064cb0 100644 --- a/wacore/src/pair.rs +++ b/wacore/src/pair.rs @@ -3,16 +3,16 @@ use crate::libsignal::crypto::aes_256_gcm_encrypt; use crate::libsignal::protocol::{KeyPair, PublicKey}; use base64::Engine as _; use base64::prelude::*; +use buffa::Message; use hkdf::Hkdf; use hmac::{Hmac, Mac}; -use prost::Message; use sha2::Sha256; use wacore_binary::builder::NodeBuilder; use wacore_binary::{Jid, SERVER_JID}; use wacore_binary::{Node, NodeRef}; use waproto::whatsapp as wa; -use waproto::whatsapp::AdvEncryptionType; +use waproto::whatsapp::ADVEncryptionType; // Prefixes from whatsmeow/pair.go, crucial for signature verification const ADV_PREFIX_ACCOUNT_SIGNATURE: &[u8] = &[6, 0]; @@ -144,7 +144,7 @@ impl PairUtils { let bytes = success_node .get_optional_child_by_tag(&["client-props"])? .content_bytes()?; - wa::ClientPairingProps::decode(bytes).ok() + wa::ClientPairingProps::decode_from_slice(bytes).ok() } /// Pair-time `lid_migrated` write decision. `Some(true)` whenever the @@ -169,16 +169,17 @@ impl PairUtils { device_identity_bytes: &[u8], ) -> Result<(Vec, u32), PairCryptoError> { // 1. Unmarshal HMAC container and verify HMAC - let hmac_container = wa::AdvSignedDeviceIdentityHmac::decode(device_identity_bytes) - .map_err(|e| PairCryptoError { - code: 500, - text: "internal-error", - source: e.into(), - })?; + let hmac_container = wa::ADVSignedDeviceIdentityHMAC::decode_from_slice( + device_identity_bytes, + ) + .map_err(|e| PairCryptoError { + code: 500, + text: "internal-error", + source: e.into(), + })?; // Determine if this is a hosted account - let is_hosted_account = hmac_container.account_type.is_some() - && hmac_container.account_type() == AdvEncryptionType::Hosted; + let is_hosted_account = hmac_container.account_type == Some(ADVEncryptionType::HOSTED); let mut mac = ::new_from_slice(&device_state.adv_secret_key) .map_err(|e| PairCryptoError { @@ -219,16 +220,26 @@ impl PairUtils { })?; // 2. Unmarshal inner container and verify account signature - let mut signed_identity = - wa::AdvSignedDeviceIdentity::decode(details_bytes).map_err(|e| PairCryptoError { + let mut signed_identity = wa::ADVSignedDeviceIdentity::decode_from_slice(details_bytes) + .map_err(|e| PairCryptoError { code: 500, text: "internal-error", source: e.into(), })?; - let account_sig_key_bytes = signed_identity.account_signature_key(); - let account_sig_bytes = signed_identity.account_signature(); - let inner_details_bytes = signed_identity.details().to_vec(); + let account_sig_key_bytes = signed_identity + .account_signature_key + .as_deref() + .unwrap_or_default(); + let account_sig_bytes = signed_identity + .account_signature + .as_deref() + .unwrap_or_default(); + let inner_details_bytes = signed_identity + .details + .as_deref() + .unwrap_or_default() + .to_vec(); let account_sig_prefix = if is_hosted_account { ADV_HOSTED_PREFIX_ACCOUNT_SIGNATURE @@ -282,13 +293,17 @@ impl PairUtils { signed_identity.device_signature = Some(device_signature.to_vec()); // 4. Unmarshal final details to get key_index - let identity_details = - wa::AdvDeviceIdentity::decode(&*inner_details_bytes).map_err(|e| PairCryptoError { + let identity_details = wa::ADVDeviceIdentity::decode_from_slice(&inner_details_bytes) + .map_err(|e| PairCryptoError { code: 500, text: "internal-error", source: e.into(), })?; - let key_index = identity_details.key_index(); + let key_index = identity_details.key_index.ok_or_else(|| PairCryptoError { + code: 500, + text: "internal-error", + source: anyhow::anyhow!("ADVDeviceIdentity missing key_index"), + })?; // 5. Marshal the modified signed_identity to send back let self_signed_identity_bytes = signed_identity.encode_to_vec(); @@ -592,23 +607,23 @@ mod tests { use waproto::whatsapp as wa; let cases = [ - (wa::device_props::PlatformType::Chrome, "1"), - (wa::device_props::PlatformType::Firefox, "3"), - (wa::device_props::PlatformType::Safari, "6"), - (wa::device_props::PlatformType::Edge, "2"), - (wa::device_props::PlatformType::Desktop, "7"), - (wa::device_props::PlatformType::Uwp, "8"), - (wa::device_props::PlatformType::AndroidPhone, "1"), - (wa::device_props::PlatformType::AndroidTablet, "1"), - (wa::device_props::PlatformType::AndroidAmbiguous, "1"), - (wa::device_props::PlatformType::IosPhone, "9"), - (wa::device_props::PlatformType::Vr, "9"), - (wa::device_props::PlatformType::Unknown, "9"), + (wa::device_props::PlatformType::CHROME, "1"), + (wa::device_props::PlatformType::FIREFOX, "3"), + (wa::device_props::PlatformType::SAFARI, "6"), + (wa::device_props::PlatformType::EDGE, "2"), + (wa::device_props::PlatformType::DESKTOP, "7"), + (wa::device_props::PlatformType::UWP, "8"), + (wa::device_props::PlatformType::ANDROID_PHONE, "1"), + (wa::device_props::PlatformType::ANDROID_TABLET, "1"), + (wa::device_props::PlatformType::ANDROID_AMBIGUOUS, "1"), + (wa::device_props::PlatformType::IOS_PHONE, "9"), + (wa::device_props::PlatformType::VR, "9"), + (wa::device_props::PlatformType::UNKNOWN, "9"), ]; let state = dummy_device_state(); for (pt, expected_wire) in cases { let props = wa::DeviceProps { - platform_type: Some(pt as i32), + platform_type: Some(pt), ..Default::default() }; let ct = companion_web_client_type_for_props(&props); @@ -666,18 +681,31 @@ mod tests { adv_secret_for_hmac: &[u8; 32], is_hosted: bool, ) -> Vec { - use prost::Message; + build_pair_success_payload_with_key_index(state, adv_secret_for_hmac, is_hosted, Some(0)) + } + + fn build_pair_success_payload_with_key_index( + state: &DeviceState, + adv_secret_for_hmac: &[u8; 32], + is_hosted: bool, + key_index: Option, + ) -> Vec { + use buffa::Message; use waproto::whatsapp as wa; let mut rng = rand::make_rng::(); let account_kp = KeyPair::generate(&mut rng); - let account_type_value = if is_hosted { 1 } else { 0 }; - let inner = wa::AdvDeviceIdentity { + let account_type = if is_hosted { + wa::ADVEncryptionType::HOSTED + } else { + wa::ADVEncryptionType::E2EE + }; + let inner = wa::ADVDeviceIdentity { raw_id: Some(1), timestamp: Some(0), - key_index: Some(0), - account_type: Some(account_type_value), - device_type: Some(account_type_value), + key_index, + account_type: Some(account_type), + device_type: Some(account_type), } .encode_to_vec(); let account_sig_prefix: &[u8] = if is_hosted { @@ -693,7 +721,7 @@ mod tests { .private_key .calculate_signature(&to_sign, &mut rng) .unwrap(); - let signed = wa::AdvSignedDeviceIdentity { + let signed = wa::ADVSignedDeviceIdentity { details: Some(inner), account_signature_key: Some(account_kp.public_key.public_key_bytes().to_vec()), account_signature: Some(sig.to_vec()), @@ -706,10 +734,10 @@ mod tests { } mac.update(&signed); let hmac_bytes = mac.finalize().into_bytes().to_vec(); - wa::AdvSignedDeviceIdentityHmac { + wa::ADVSignedDeviceIdentityHMAC { details: Some(signed), hmac: Some(hmac_bytes), - account_type: Some(account_type_value), + account_type: Some(account_type), } .encode_to_vec() } @@ -801,6 +829,19 @@ mod tests { .expect("device-identity HMAC must verify for the passkey pair-success"); } + #[test] + fn do_pair_crypto_rejects_missing_key_index() { + let state = dummy_device_state(); + let payload = + build_pair_success_payload_with_key_index(&state, &state.adv_secret_key, false, None); + + let err = PairUtils::do_pair_crypto(&state, &payload) + .expect_err("missing key_index should abort pairing"); + + assert_eq!(err.code, 500); + assert!(err.source.to_string().contains("missing key_index")); + } + #[test] fn extract_pairing_props_decodes_client_props_child() { let pair_success = NodeBuilder::new("pair-success") @@ -822,7 +863,7 @@ mod tests { let props = PairUtils::extract_pairing_props(&pair_success.as_node_ref()) .expect("client-props child must decode"); - assert!(props.is_chat_db_lid_migrated()); + assert!(props.is_chat_db_lid_migrated.unwrap_or(false)); } #[test] @@ -841,7 +882,7 @@ mod tests { let props = PairUtils::extract_pairing_props(&pair_success.as_node_ref()) .expect("client-props child must decode"); - assert!(!props.is_chat_db_lid_migrated()); + assert!(!props.is_chat_db_lid_migrated.unwrap_or(false)); } #[test] @@ -854,7 +895,7 @@ mod tests { let props = PairUtils::extract_pairing_props(&pair_success.as_node_ref()) .expect("client-props child must decode"); - assert!(!props.is_chat_db_lid_migrated()); + assert!(!props.is_chat_db_lid_migrated.unwrap_or(false)); } #[test] diff --git a/wacore/src/pair_code.rs b/wacore/src/pair_code.rs index ef40217a0..1b9283a18 100644 --- a/wacore/src/pair_code.rs +++ b/wacore/src/pair_code.rs @@ -687,14 +687,14 @@ mod tests { fn props(os: Option<&str>, pt: Option) -> wa::DeviceProps { wa::DeviceProps { os: os.map(|s| s.to_string()), - platform_type: pt.map(|v| v as i32), + platform_type: pt, ..Default::default() } } #[test] fn derive_chrome_linux_matches_wa_web() { - let p = props(Some("Linux"), Some(wa::device_props::PlatformType::Chrome)); + let p = props(Some("Linux"), Some(wa::device_props::PlatformType::CHROME)); assert_eq!( derive_companion_platform(&p), (CompanionWebClientType::Chrome, "Chrome (Linux)".to_string()) @@ -703,7 +703,7 @@ mod tests { #[test] fn derive_firefox_uses_companion_web_client_wire() { - let p = props(Some("Linux"), Some(wa::device_props::PlatformType::Firefox)); + let p = props(Some("Linux"), Some(wa::device_props::PlatformType::FIREFOX)); let (id, display) = derive_companion_platform(&p); assert_eq!(id, CompanionWebClientType::Firefox); assert_eq!(id.wire_byte(), b'3'); @@ -712,7 +712,7 @@ mod tests { #[test] fn derive_edge_uses_companion_web_client_wire() { - let p = props(Some("Windows"), Some(wa::device_props::PlatformType::Edge)); + let p = props(Some("Windows"), Some(wa::device_props::PlatformType::EDGE)); let (id, display) = derive_companion_platform(&p); assert_eq!(id, CompanionWebClientType::Edge); assert_eq!(id.wire_byte(), b'2'); @@ -722,7 +722,7 @@ mod tests { #[test] fn derive_android_platform_types_map_to_chrome() { use wa::device_props::PlatformType as P; - for pt in [P::AndroidPhone, P::AndroidTablet, P::AndroidAmbiguous] { + for pt in [P::ANDROID_PHONE, P::ANDROID_TABLET, P::ANDROID_AMBIGUOUS] { let (id, display) = derive_companion_platform(&props(Some("Android"), Some(pt))); assert_eq!(id, CompanionWebClientType::Chrome, "{pt:?}"); assert_eq!(id.wire_byte(), b'1', "{pt:?}"); @@ -732,7 +732,7 @@ mod tests { #[test] fn derive_ios_phone_falls_back_to_other_web_client_and_chrome() { - let p = props(Some("iOS"), Some(wa::device_props::PlatformType::IosPhone)); + let p = props(Some("iOS"), Some(wa::device_props::PlatformType::IOS_PHONE)); let (id, display) = derive_companion_platform(&p); assert_eq!(id, CompanionWebClientType::OtherWebClient); assert_eq!(display, "Chrome (iOS)"); @@ -740,7 +740,7 @@ mod tests { #[test] fn derive_no_os_substitutes_linux() { - let p = props(None, Some(wa::device_props::PlatformType::Chrome)); + let p = props(None, Some(wa::device_props::PlatformType::CHROME)); assert_eq!( derive_companion_platform(&p), (CompanionWebClientType::Chrome, "Chrome (Linux)".to_string()) @@ -749,7 +749,7 @@ mod tests { #[test] fn derive_empty_os_substitutes_linux() { - let p = props(Some(" "), Some(wa::device_props::PlatformType::Chrome)); + let p = props(Some(" "), Some(wa::device_props::PlatformType::CHROME)); assert_eq!( derive_companion_platform(&p), (CompanionWebClientType::Chrome, "Chrome (Linux)".to_string()) @@ -776,31 +776,31 @@ mod tests { "Chrome", "Edge", "Firefox", "IE", "Opera", "Safari", "Android", ]; for pt in [ - P::Unknown, - P::Chrome, - P::Firefox, - P::Ie, - P::Opera, - P::Safari, - P::Edge, - P::Desktop, - P::Ipad, - P::AndroidTablet, - P::Ohana, - P::Aloha, - P::Catalina, - P::TclTv, - P::IosPhone, - P::IosCatalyst, - P::AndroidPhone, - P::AndroidAmbiguous, - P::WearOs, - P::ArWrist, - P::ArDevice, - P::Uwp, - P::Vr, - P::CloudApi, - P::Smartglasses, + P::UNKNOWN, + P::CHROME, + P::FIREFOX, + P::IE, + P::OPERA, + P::SAFARI, + P::EDGE, + P::DESKTOP, + P::IPAD, + P::ANDROID_TABLET, + P::OHANA, + P::ALOHA, + P::CATALINA, + P::TCL_TV, + P::IOS_PHONE, + P::IOS_CATALYST, + P::ANDROID_PHONE, + P::ANDROID_AMBIGUOUS, + P::WEAR_OS, + P::AR_WRIST, + P::AR_DEVICE, + P::UWP, + P::VR, + P::CLOUD_API, + P::SMARTGLASSES, ] { let p = props(Some("Linux"), Some(pt)); let (id, display) = derive_companion_platform(&p); @@ -825,7 +825,7 @@ mod tests { fn resolve_explicit_id_overrides_derived() { let p = props( Some("Android"), - Some(wa::device_props::PlatformType::AndroidPhone), + Some(wa::device_props::PlatformType::ANDROID_PHONE), ); let opts = PairCodeOptions { platform_id: Some(CompanionWebClientType::Chrome), @@ -842,7 +842,7 @@ mod tests { #[test] fn resolve_default_uses_derived() { - let p = props(Some("Linux"), Some(wa::device_props::PlatformType::Edge)); + let p = props(Some("Linux"), Some(wa::device_props::PlatformType::EDGE)); assert_eq!( resolve_companion_platform(&PairCodeOptions::default(), &p), (CompanionWebClientType::Edge, "Edge (Linux)".to_string()) @@ -1107,7 +1107,7 @@ mod tests { fn android_device_props_emit_server_accepted_companion_hello() { let props = wa::DeviceProps { os: Some("Android".into()), - platform_type: Some(wa::device_props::PlatformType::AndroidPhone as i32), + platform_type: Some(wa::device_props::PlatformType::ANDROID_PHONE), ..Default::default() }; let (pid, pdisp) = resolve_companion_platform(&PairCodeOptions::default(), &props); @@ -1130,7 +1130,7 @@ mod tests { fn explicit_options_override_id_and_display_follows() { let props = wa::DeviceProps { os: Some("Android".into()), - platform_type: Some(wa::device_props::PlatformType::AndroidPhone as i32), + platform_type: Some(wa::device_props::PlatformType::ANDROID_PHONE), ..Default::default() }; let opts = PairCodeOptions { @@ -1146,7 +1146,7 @@ mod tests { #[test] fn pair_code_id_matches_qr_id_for_same_device_props() { use crate::companion_reg::companion_web_client_type_for_props; - let p = props(Some("Linux"), Some(wa::device_props::PlatformType::Edge)); + let p = props(Some("Linux"), Some(wa::device_props::PlatformType::EDGE)); let (pair_code_id, _) = derive_companion_platform(&p); let qr_id = companion_web_client_type_for_props(&p); assert_eq!(pair_code_id, qr_id); diff --git a/wacore/src/poll.rs b/wacore/src/poll.rs index 7d3a72a74..b9054cbbc 100644 --- a/wacore/src/poll.rs +++ b/wacore/src/poll.rs @@ -62,14 +62,9 @@ pub fn encrypt_poll_vote( voter_jid: &str, ) -> Result<(Vec, [u8; GCM_IV_SIZE])> { use crate::libsignal::crypto::aes_256_gcm_encrypt; - use prost::Message; use rand::Rng; - let vote_msg = waproto::whatsapp::message::PollVoteMessage { - selected_options: selected_option_hashes.to_vec(), - }; - let mut plaintext = Vec::new(); - vote_msg.encode(&mut plaintext)?; + let plaintext = encode_selected_options(selected_option_hashes); let mut iv = [0u8; GCM_IV_SIZE]; rand::make_rng::().fill_bytes(&mut iv); @@ -93,13 +88,7 @@ pub fn encrypt_poll_vote_with_secret( poll_creator_jid: &str, voter_jid: &str, ) -> Result<(Vec, [u8; GCM_IV_SIZE])> { - use prost::Message; - - let vote_msg = waproto::whatsapp::message::PollVoteMessage { - selected_options: selected_option_hashes.to_vec(), - }; - let mut plaintext = Vec::new(); - vote_msg.encode(&mut plaintext)?; + let plaintext = encode_selected_options(selected_option_hashes); encrypt_addon( &plaintext, @@ -108,6 +97,21 @@ pub fn encrypt_poll_vote_with_secret( ) } +fn encode_selected_options(selected_option_hashes: &[Vec]) -> Vec { + use buffa::encoding::{Tag, WireType}; + + let encoded_len = selected_option_hashes + .iter() + .map(|hash| 1 + buffa::types::bytes_encoded_len(hash)) + .sum(); + let mut plaintext = Vec::with_capacity(encoded_len); + for hash in selected_option_hashes { + Tag::new(1, WireType::LengthDelimited).encode(&mut plaintext); + buffa::types::encode_bytes(hash, &mut plaintext); + } + plaintext +} + /// Returns the selected option hashes (each 32 bytes). /// /// Kept for backwards compatibility with callers that pre-derived the key. @@ -120,7 +124,6 @@ pub fn decrypt_poll_vote( voter_jid: &str, ) -> Result>> { use crate::libsignal::crypto::aes_256_gcm_decrypt; - use prost::Message as _; let nonce: &[u8; GCM_IV_SIZE] = iv .try_into() @@ -139,8 +142,7 @@ pub fn decrypt_poll_vote( aes_256_gcm_decrypt(encryption_key, nonce, &aad, enc_payload, &mut plaintext) .map_err(|_| anyhow!("Poll vote GCM tag verification failed"))?; - let vote_msg = waproto::whatsapp::message::PollVoteMessage::decode(&plaintext[..])?; - Ok(vote_msg.selected_options) + decode_selected_options(&plaintext) } /// Creator + voter JIDs (non-AD) that key the poll-vote HKDF and AAD. @@ -196,6 +198,50 @@ pub fn decrypt_poll_vote_with_fallback( } } +/// Decrypts a poll vote and visits borrowed selected option hashes. +/// +/// This avoids copying each selected hash when the caller can consume it before +/// the decrypted plaintext buffer is dropped. +pub fn visit_decrypted_poll_vote_with_fallback( + enc_payload: &[u8], + iv: &[u8], + message_secret: &[u8], + stanza_id: &str, + primary: PollVoteAddressing<'_>, + fallback: Option>, + mut visit: F, +) -> Result<()> +where + F: FnMut(&[u8]), +{ + match visit_poll_vote_with_secret( + enc_payload, + iv, + message_secret, + stanza_id, + primary.poll_creator_jid, + primary.voter_jid, + &mut visit, + ) { + Ok(()) => Ok(()), + Err(primary_err) => match fallback { + Some(fb) => visit_poll_vote_with_secret( + enc_payload, + iv, + message_secret, + stanza_id, + fb.poll_creator_jid, + fb.voter_jid, + &mut visit, + ) + .map_err(|fb_err| { + anyhow!("poll vote decrypt failed: primary={primary_err}; fallback={fb_err}") + }), + None => Err(primary_err), + }, + } +} + /// Decrypt a poll vote given the poll's `messageSecret` directly. Preferred /// over the legacy two-step path that splits derive+decrypt. pub fn decrypt_poll_vote_with_secret( @@ -205,16 +251,76 @@ pub fn decrypt_poll_vote_with_secret( poll_creator_jid: &str, voter_jid: &str, ) -> Result>> { - use prost::Message as _; - let plaintext = decrypt_addon( ciphertext.enc_payload, ciphertext.enc_iv, message_secret, &poll_vote_addon_ctx(stanza_id, poll_creator_jid, voter_jid), )?; - let vote_msg = waproto::whatsapp::message::PollVoteMessage::decode(&plaintext[..])?; - Ok(vote_msg.selected_options) + decode_selected_options(&plaintext) +} + +fn visit_poll_vote_with_secret( + enc_payload: &[u8], + iv: &[u8], + message_secret: &[u8], + stanza_id: &str, + poll_creator_jid: &str, + voter_jid: &str, + visit: &mut F, +) -> Result<()> +where + F: FnMut(&[u8]), +{ + let plaintext = decrypt_addon( + enc_payload, + iv, + message_secret, + &poll_vote_addon_ctx(stanza_id, poll_creator_jid, voter_jid), + )?; + // Validate the entire plaintext BEFORE emitting anything: `visit` has + // observable side effects in the caller and the caller has a fallback path + // that re-runs this on failure. Without the dry first pass, a parse error + // partway through would leave the caller with partial emissions plus a + // fallback re-emit (duplicated/corrupt votes). The first scan makes this + // visit all-or-nothing; the plaintext is small so the double pass is cheap. + scan_selected_options(&plaintext, |_| {})?; + scan_selected_options(&plaintext, visit) +} + +fn decode_selected_options(plaintext: &[u8]) -> Result>> { + let mut selected_options = Vec::new(); + scan_selected_options(plaintext, |selected| { + selected_options.push(selected.to_vec()); + })?; + Ok(selected_options) +} + +fn scan_selected_options<'a, F>(plaintext: &'a [u8], mut visit: F) -> Result<()> +where + F: FnMut(&'a [u8]), +{ + use buffa::encoding::{Tag, WireType, skip_field_depth}; + + let mut cur = plaintext; + while !cur.is_empty() { + let tag = Tag::decode(&mut cur)?; + match tag.field_number() { + 1 => { + if tag.wire_type() != WireType::LengthDelimited { + return Err(buffa::DecodeError::WireTypeMismatch { + field_number: 1, + expected: WireType::LengthDelimited as u8, + actual: tag.wire_type() as u8, + } + .into()); + } + visit(buffa::types::borrow_bytes(&mut cur)?); + } + _ => skip_field_depth(tag, &mut cur, buffa::RECURSION_LIMIT)?, + } + } + Ok(()) } #[cfg(test)] @@ -259,6 +365,21 @@ mod tests { assert_eq!(out, hashes); } + #[test] + fn selected_option_encoding_matches_message_encoding() { + use buffa::Message; + + let hashes = vec![ + compute_option_hash("Yes").to_vec(), + compute_option_hash("No").to_vec(), + ]; + let vote_msg = waproto::whatsapp::message::PollVoteMessage { + selected_options: hashes.clone(), + }; + + assert_eq!(encode_selected_options(&hashes), vote_msg.encode_to_vec()); + } + #[test] fn legacy_decrypt_path_still_works() { let secret = [0xCDu8; 32]; @@ -342,6 +463,43 @@ mod tests { assert_eq!(out, vec![compute_option_hash("Yes").to_vec()]); } + #[test] + fn visit_fallback_yields_selected_hashes() { + let secret = [0x34u8; 32]; + let stanza_id = "3EB0VISIT"; + let creator_pn = "5511999999999@s.whatsapp.net"; + let voter_pn = "5511888888888@s.whatsapp.net"; + let expected = compute_option_hash("Yes"); + let (enc, iv) = encrypt_poll_vote_with_secret( + &[expected.to_vec()], + &secret, + stanza_id, + creator_pn, + voter_pn, + ) + .unwrap(); + + let mut visited = Vec::new(); + visit_decrypted_poll_vote_with_fallback( + &enc, + &iv, + &secret, + stanza_id, + PollVoteAddressing { + poll_creator_jid: "111111111111111@lid", + voter_jid: "222222222222222@lid", + }, + Some(PollVoteAddressing { + poll_creator_jid: creator_pn, + voter_jid: voter_pn, + }), + |hash| visited.push(<[u8; 32]>::try_from(hash).unwrap()), + ) + .unwrap(); + + assert_eq!(visited, vec![expected]); + } + #[test] fn fallback_primary_succeeds_without_using_fallback() { let secret = [0x44u8; 32]; diff --git a/wacore/src/prekeys.rs b/wacore/src/prekeys.rs index b29bf59ed..916b73ad5 100644 --- a/wacore/src/prekeys.rs +++ b/wacore/src/prekeys.rs @@ -421,7 +421,7 @@ mod tests { #[test] fn extract_prekey_public_key_matches_full_decode_validation() { - use prost::Message; + use buffa::Message; let public_key = vec![0x05u8; 33]; let record = waproto::whatsapp::PreKeyRecordStructure { id: Some(1), @@ -441,7 +441,7 @@ mod tests { // it; the extractor must agree (return None) so the upload path never ships // a record the consume path's full decode would later reject. let truncated = &record[..record.len() - 1]; - assert!(waproto::whatsapp::PreKeyRecordStructure::decode(truncated).is_err()); + assert!(waproto::whatsapp::PreKeyRecordStructure::decode_from_slice(truncated).is_err()); assert_eq!(extract_prekey_public_key(truncated), None); // A malformed varint in a trailing field (10 continuation bytes that never @@ -450,14 +450,18 @@ mod tests { let mut bad_varint = record.clone(); bad_varint.push(0x08); // field 1, wire type 0 (varint) bad_varint.extend_from_slice(&[0xFF; 10]); - assert!(waproto::whatsapp::PreKeyRecordStructure::decode(&bad_varint[..]).is_err()); + assert!( + waproto::whatsapp::PreKeyRecordStructure::decode_from_slice(&bad_varint[..]).is_err() + ); assert_eq!(extract_prekey_public_key(&bad_varint), None); // An unsupported wire type (3 = start group): prost rejects the dangling // group, and the extractor rejects every wire type it cannot frame. let mut bad_wire = record.clone(); bad_wire.push(0x0B); // field 1, wire type 3 (start group) - assert!(waproto::whatsapp::PreKeyRecordStructure::decode(&bad_wire[..]).is_err()); + assert!( + waproto::whatsapp::PreKeyRecordStructure::decode_from_slice(&bad_wire[..]).is_err() + ); assert_eq!(extract_prekey_public_key(&bad_wire), None); } @@ -572,7 +576,7 @@ mod tests { device: &crate::libsignal::protocol::KeyPair, details: &[u8], ) -> Vec { - use prost::Message; + use buffa::Message; let mut rng = rand::make_rng::(); let identity = device.public_key.public_key_bytes(); let account_key = account.public_key.public_key_bytes(); @@ -589,7 +593,7 @@ mod tests { ) .unwrap() .to_vec(); - waproto::whatsapp::AdvSignedDeviceIdentity { + waproto::whatsapp::ADVSignedDeviceIdentity { details: Some(details.to_vec()), account_signature_key: None, account_signature: Some(account_sig), diff --git a/wacore/src/proto_helpers.rs b/wacore/src/proto_helpers.rs index ae3447551..868309793 100644 --- a/wacore/src/proto_helpers.rs +++ b/wacore/src/proto_helpers.rs @@ -58,8 +58,10 @@ macro_rules! for_each_context_info_message { macro_rules! for_each_context_info_impl { ($msg:expr, $ctx:ident, $body:block, $($field:ident),+ $(,)?) => { $( - if let Some(ref mut m) = $msg.$field { - if let Some(ref mut $ctx) = m.context_info $body + if let Some(m) = $msg.$field.as_option_mut() + && let Some($ctx) = m.context_info.as_option_mut() + { + $body } )+ }; @@ -77,8 +79,8 @@ macro_rules! find_context_info_impl { let mut found: Option<&wa::ContextInfo> = None; $( if found.is_none() - && let Some(ref m) = $msg.$field - && let Some(ref ctx) = m.context_info + && let Some(m) = $msg.$field.as_option() + && let Some(ctx) = m.context_info.as_option() { found = Some(ctx); } @@ -109,11 +111,11 @@ impl MessageBuilderExt for wa::Message { fn text_with_context(text: impl Into, context: wa::ContextInfo) -> wa::Message { wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some(text.into()), - context_info: Some(Box::new(context)), + context_info: buffa::MessageField::some(context), ..Default::default() - })), + }), ..Default::default() } } @@ -148,7 +150,7 @@ pub trait MessageExt { /// let context_info = wa::ContextInfo { /// stanza_id: Some(message_id.clone()), /// participant: Some(sender_jid.to_string()), - /// quoted_message: Some(original_message.prepare_for_quote()), + /// quoted_message: buffa::MessageField::from_box(original_message.prepare_for_quote()), /// ..Default::default() /// }; /// ``` @@ -177,17 +179,17 @@ pub trait MessageExt { /// use wacore::proto_helpers::MessageExt; /// /// let mut reply = wa::Message { - /// image_message: Some(Box::new(wa::message::ImageMessage { + /// image_message: buffa::MessageField::some(wa::message::ImageMessage { /// // ... image data /// ..Default::default() - /// })), + /// }), /// ..Default::default() /// }; /// /// let context = wa::ContextInfo { /// stanza_id: Some("original-msg-id".to_string()), /// participant: Some("sender@s.whatsapp.net".to_string()), - /// quoted_message: Some(original_msg.prepare_for_quote()), + /// quoted_message: buffa::MessageField::from_box(original_msg.prepare_for_quote()), /// ..Default::default() /// }; /// @@ -221,52 +223,38 @@ pub trait MessageExt { impl MessageExt for wa::Message { fn get_base_message(&self) -> &wa::Message { let mut current = self; - if let Some(msg) = self - .device_sent_message - .as_ref() - .and_then(|m| m.message.as_ref()) + if let Some(dsm) = self.device_sent_message.as_option() + && let Some(msg) = dsm.message.as_option() { current = msg; } - if let Some(msg) = current - .ephemeral_message - .as_ref() - .and_then(|m| m.message.as_ref()) + if let Some(wrapper) = current.ephemeral_message.as_option() + && let Some(msg) = wrapper.message.as_option() { current = msg; } - if let Some(msg) = current - .view_once_message - .as_ref() - .and_then(|m| m.message.as_ref()) + if let Some(wrapper) = current.view_once_message.as_option() + && let Some(msg) = wrapper.message.as_option() { current = msg; } - if let Some(msg) = current - .view_once_message_v2 - .as_ref() - .and_then(|m| m.message.as_ref()) + if let Some(wrapper) = current.view_once_message_v2.as_option() + && let Some(msg) = wrapper.message.as_option() { current = msg; } - if let Some(msg) = current - .view_once_message_v2_extension - .as_ref() - .and_then(|m| m.message.as_ref()) + if let Some(wrapper) = current.view_once_message_v2_extension.as_option() + && let Some(msg) = wrapper.message.as_option() { current = msg; } - if let Some(msg) = current - .document_with_caption_message - .as_ref() - .and_then(|m| m.message.as_ref()) + if let Some(wrapper) = current.document_with_caption_message.as_option() + && let Some(msg) = wrapper.message.as_option() { current = msg; } - if let Some(msg) = current - .edited_message - .as_ref() - .and_then(|m| m.message.as_ref()) + if let Some(wrapper) = current.edited_message.as_option() + && let Some(msg) = wrapper.message.as_option() { current = msg; } @@ -278,9 +266,9 @@ impl MessageExt for wa::Message { ($field:ident) => { if let Some(mut wrapper) = self.$field.take() { if let Some(msg) = wrapper.message.take() { - self = *msg; + self = msg; } else { - self.$field = Some(wrapper); + self.$field = buffa::MessageField::some(wrapper); } } }; @@ -297,30 +285,30 @@ impl MessageExt for wa::Message { } fn is_ephemeral(&self) -> bool { - self.ephemeral_message.is_some() + self.ephemeral_message.is_set() } fn is_view_once(&self) -> bool { let mut current = self; loop { - if current.view_once_message.is_some() - || current.view_once_message_v2.is_some() - || current.view_once_message_v2_extension.is_some() + if current.view_once_message.is_set() + || current.view_once_message_v2.is_set() + || current.view_once_message_v2_extension.is_set() { return true; } if let Some(inner) = current .device_sent_message - .as_ref() - .and_then(|m| m.message.as_ref()) + .as_option() + .and_then(|m| m.message.as_option()) { current = inner; continue; } if let Some(inner) = current .ephemeral_message - .as_ref() - .and_then(|m| m.message.as_ref()) + .as_option() + .and_then(|m| m.message.as_option()) { current = inner; continue; @@ -330,17 +318,17 @@ impl MessageExt for wa::Message { let base = self.get_base_message(); matches!( - base.image_message.as_deref().and_then(|m| m.view_once), + base.image_message.as_option().and_then(|m| m.view_once), Some(true) ) || matches!( - base.video_message.as_deref().and_then(|m| m.view_once), + base.video_message.as_option().and_then(|m| m.view_once), Some(true) ) || matches!( - base.audio_message.as_deref().and_then(|m| m.view_once), + base.audio_message.as_option().and_then(|m| m.view_once), Some(true) ) || matches!( base.extended_text_message - .as_deref() + .as_option() .and_then(|m| m.view_once), Some(true) ) @@ -348,13 +336,13 @@ impl MessageExt for wa::Message { fn get_caption(&self) -> Option<&str> { let base = self.get_base_message(); - if let Some(msg) = &base.image_message { + if let Some(msg) = base.image_message.as_option() { return msg.caption.as_deref(); } - if let Some(msg) = &base.video_message { + if let Some(msg) = base.video_message.as_option() { return msg.caption.as_deref(); } - if let Some(msg) = &base.document_message { + if let Some(msg) = base.document_message.as_option() { return msg.caption.as_deref(); } None @@ -367,7 +355,7 @@ impl MessageExt for wa::Message { { return Some(text); } - if let Some(ext_text) = &base.extended_text_message + if let Some(ext_text) = base.extended_text_message.as_option() && let Some(text) = &ext_text.text { return Some(text); @@ -391,15 +379,13 @@ impl MessageExt for wa::Message { // including for bot participants (no quote-preserve exception). strip_nested_context_info(&mut msg, true); // WA Web forward omits messageSecret; the send path generates a fresh one. - msg.message_context_info = None; + msg.message_context_info = buffa::MessageField::none(); macro_rules! set_forward { ($($field:ident),+ $(,)?) => { $( - if let Some(ref mut m) = msg.$field { - let ctx = m - .context_info - .get_or_insert_with(|| Box::new(wa::ContextInfo::default())); + if let Some(m) = msg.$field.as_option_mut() { + let ctx = m.context_info.get_or_insert_default(); let n = ctx .forwarding_score .unwrap_or(0) @@ -420,15 +406,16 @@ impl MessageExt for wa::Message { // Bare conversation carries no context_info; promote it like the other // setters do so the forward marker can attach. if let Some(text) = msg.conversation.take() { - msg.extended_text_message = Some(Box::new(wa::message::ExtendedTextMessage { - text: Some(text), - context_info: Some(Box::new(wa::ContextInfo { - is_forwarded: Some(true), - forwarding_score: Some(0), + msg.extended_text_message = + buffa::MessageField::some(wa::message::ExtendedTextMessage { + text: Some(text), + context_info: buffa::MessageField::some(wa::ContextInfo { + is_forwarded: Some(true), + forwarding_score: Some(0), + ..Default::default() + }), ..Default::default() - })), - ..Default::default() - })); + }); } Box::new(msg) } @@ -437,8 +424,8 @@ impl MessageExt for wa::Message { macro_rules! try_attach { ($($field:ident),+ $(,)?) => { $( - if let Some(ref mut m) = self.$field { - m.context_info = Some(Box::new(context)); + if let Some(m) = self.$field.as_option_mut() { + m.context_info = buffa::MessageField::some(context); return true; } )+ @@ -449,11 +436,12 @@ impl MessageExt for wa::Message { // Promote bare conversation to extended_text_message so the context // can attach; matches WAWebMessageSendUtils. if let Some(text) = self.conversation.take() { - self.extended_text_message = Some(Box::new(wa::message::ExtendedTextMessage { - text: Some(text), - context_info: Some(Box::new(context)), - ..Default::default() - })); + self.extended_text_message = + buffa::MessageField::some(wa::message::ExtendedTextMessage { + text: Some(text), + context_info: buffa::MessageField::some(context), + ..Default::default() + }); return true; } false @@ -463,14 +451,12 @@ impl MessageExt for wa::Message { macro_rules! check { ($($field:ident),+ $(,)?) => { $( - if let Some(ref m) = self.$field { - if let Some(ref ctx) = m.context_info { - if let Some(exp) = ctx.expiration { - if exp > 0 { - return Some(exp); - } - } - } + if let Some(m) = self.$field.as_option() + && let Some(ctx) = m.context_info.as_option() + && let Some(exp) = ctx.expiration + && exp > 0 + { + return Some(exp); } )+ }; @@ -486,8 +472,8 @@ impl MessageExt for wa::Message { macro_rules! try_set { ($($field:ident),+ $(,)?) => { $( - if let Some(ref mut m) = self.$field { - let ctx = m.context_info.get_or_insert_with(|| Box::new(wa::ContextInfo::default())); + if let Some(m) = self.$field.as_option_mut() { + let ctx = m.context_info.get_or_insert_default(); ctx.expiration = Some(expiration); return true; } @@ -499,14 +485,15 @@ impl MessageExt for wa::Message { // Promote bare conversation so the timer can attach; matches // WAWebMessageSendUtils. if let Some(text) = self.conversation.take() { - self.extended_text_message = Some(Box::new(wa::message::ExtendedTextMessage { - text: Some(text), - context_info: Some(Box::new(wa::ContextInfo { - expiration: Some(expiration), + self.extended_text_message = + buffa::MessageField::some(wa::message::ExtendedTextMessage { + text: Some(text), + context_info: buffa::MessageField::some(wa::ContextInfo { + expiration: Some(expiration), + ..Default::default() + }), ..Default::default() - })), - ..Default::default() - })); + }); return true; } @@ -555,11 +542,11 @@ pub fn build_keep_in_chat_message( wa::KeepType::UndoKeepForAll }; wa::Message { - keep_in_chat_message: Some(Box::new(wa::message::KeepInChatMessage { - key: Some(key), - keep_type: Some(keep_type as i32), + keep_in_chat_message: buffa::MessageField::some(wa::message::KeepInChatMessage { + key: buffa::MessageField::some(key), + keep_type: Some(keep_type), timestamp_ms: Some(timestamp_ms), - })), + }), ..Default::default() } } @@ -590,7 +577,7 @@ pub(crate) fn strip_nested_context_info(msg: &mut wa::Message, always_clear_quot if !is_bot { // Break the nested quote chain. - ctx.quoted_message = None; + ctx.quoted_message = Default::default(); ctx.stanza_id = None; ctx.remote_jid = None; ctx.participant = None; @@ -605,10 +592,10 @@ pub(crate) fn strip_nested_context_info(msg: &mut wa::Message, always_clear_quot macro_rules! recurse_into_wrapper { ($($wrapper:ident),+ $(,)?) => { $( - if let Some(ref mut wrapper) = msg.$wrapper { - if let Some(ref mut inner) = wrapper.message { - strip_nested_context_info(inner, always_clear_quote); - } + if let Some(wrapper) = msg.$wrapper.as_option_mut() + && let Some(inner) = wrapper.message.as_option_mut() + { + strip_nested_context_info(inner, always_clear_quote); } )+ }; @@ -623,8 +610,8 @@ pub(crate) fn strip_nested_context_info(msg: &mut wa::Message, always_clear_quot ); // device_sent_message also contains a nested message. - if let Some(ref mut wrapper) = msg.device_sent_message - && let Some(ref mut inner) = wrapper.message + if let Some(wrapper) = msg.device_sent_message.as_option_mut() + && let Some(inner) = wrapper.message.as_option_mut() { strip_nested_context_info(inner, always_clear_quote); } @@ -642,32 +629,32 @@ pub(crate) fn strip_nested_context_info(msg: &mut wa::Message, always_clear_quot /// - **`thread_id`**: inner if non-empty, otherwise outer /// - **`bot_metadata`**: inner, falling back to outer pub fn merge_dsm_context( - inner: Option>, + inner: Option, outer: Option<&wa::MessageContextInfo>, -) -> Option> { +) -> Option { match (inner, outer) { (None, None) => None, (Some(mut inner), None) => { // limit_sharing_v2 always comes from outer; clear it when outer is absent - inner.limit_sharing_v2 = None; + inner.limit_sharing_v2 = Default::default(); Some(inner) } // Inner was cleared by a WA-Web-style hoist; restore the full context the // sender moved to the outer message, not just the merge subset. - (None, Some(outer)) => Some(Box::new(outer.clone())), + (None, Some(outer)) => Some(outer.clone()), (Some(mut inner), Some(outer)) => { if inner.message_secret.is_none() { inner.message_secret = outer.message_secret.clone(); } - if inner.message_association.is_none() { + if inner.message_association.is_unset() { inner.message_association = outer.message_association.clone(); } // limit_sharing_v2: always from outer (WA Web unconditionally overrides) - inner.limit_sharing_v2 = outer.limit_sharing_v2; + inner.limit_sharing_v2 = outer.limit_sharing_v2.clone(); if inner.thread_id.is_empty() { inner.thread_id = outer.thread_id.clone(); } - if inner.bot_metadata.is_none() { + if inner.bot_metadata.is_unset() { inner.bot_metadata = outer.bot_metadata.clone(); } Some(inner) @@ -697,11 +684,11 @@ pub fn merge_dsm_context( /// ); /// /// let reply = wa::Message { -/// extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { +/// extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { /// text: Some("My reply".to_string()), -/// context_info: Some(Box::new(context)), +/// context_info: buffa::MessageField::some(context), /// ..Default::default() -/// })), +/// }), /// ..Default::default() /// }; /// ``` @@ -713,7 +700,7 @@ pub fn build_quote_context( wa::ContextInfo { stanza_id: Some(message_id.into()), participant: Some(sender_jid.into()), - quoted_message: Some(quoted_message.prepare_for_quote()), + quoted_message: buffa::MessageField::from_box(quoted_message.prepare_for_quote()), ..Default::default() } } @@ -751,7 +738,7 @@ pub fn build_quote_context_with_info( stanza_id: Some(message_id.into()), participant: Some(participant), remote_jid, - quoted_message: Some(quoted_message.prepare_for_quote()), + quoted_message: buffa::MessageField::from_box(quoted_message.prepare_for_quote()), ..Default::default() } } @@ -768,12 +755,12 @@ pub fn build_reaction_message( sender_timestamp_ms: i64, ) -> wa::Message { wa::Message { - reaction_message: Some(Box::new(wa::message::ReactionMessage { - key: Some(key), + reaction_message: buffa::MessageField::some(wa::message::ReactionMessage { + key: buffa::MessageField::some(key), text: Some(emoji.into()), sender_timestamp_ms: Some(sender_timestamp_ms), ..Default::default() - })), + }), ..Default::default() } } @@ -789,19 +776,19 @@ pub fn wrap_as_album_child( // WA Web's outgoing association (ProtoUtils.js function m) only sets // associationType + parentMessageKey, not messageIndex. let association = wa::MessageAssociation { - association_type: Some(wa::message_association::AssociationType::MediaAlbum as i32), - parent_message_key: Some(parent_key), - message_index: None, + association_type: Some(wa::message_association::AssociationType::MEDIA_ALBUM), + parent_message_key: buffa::MessageField::some(parent_key), + ..Default::default() }; let mut outer_context = existing_context.unwrap_or_default(); - outer_context.message_association = Some(association); + outer_context.message_association = buffa::MessageField::some(association); wa::Message { - associated_child_message: Some(Box::new(wa::message::FutureProofMessage { - message: Some(Box::new(inner_message)), - })), - message_context_info: Some(outer_context), + associated_child_message: buffa::MessageField::some(wa::message::FutureProofMessage { + message: buffa::MessageField::some(inner_message), + }), + message_context_info: buffa::MessageField::some(outer_context), ..Default::default() } } @@ -831,7 +818,7 @@ impl ConversationExt for wa::Conversation { use wa::group_participant::Rank; self.participant .iter() - .filter(|p| matches!(p.rank(), Rank::Admin | Rank::Superadmin)) + .filter(|p| matches!(p.rank, Some(Rank::ADMIN) | Some(Rank::SUPERADMIN))) .filter_map(|p| Jid::from_str(&p.user_jid).ok()) .collect() } @@ -855,9 +842,9 @@ mod tests { /// Creates a message with mentions in context_info. fn create_message_with_mentions() -> wa::Message { wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("Hello @user1 @user2".to_string()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { mentioned_jid: vec![ "111111@s.whatsapp.net".to_string(), "222222@s.whatsapp.net".to_string(), @@ -867,9 +854,9 @@ mod tests { group_subject: Some("Test Group".to_string()), }], ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() } } @@ -880,16 +867,16 @@ mod tests { use wa::message::extended_text_message::{FontType, PreviewType}; let original = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("Hello @user1 @user2".to_string()), matched_text: Some("https://example.com".to_string()), description: Some("Example description".to_string()), title: Some("Example Title".to_string()), text_argb: Some(0xFFFFFF), background_argb: Some(0x000000), - font: Some(FontType::SystemBold.into()), - preview_type: Some(PreviewType::Video.into()), - context_info: Some(Box::new(wa::ContextInfo { + font: Some(FontType::SYSTEM_BOLD), + preview_type: Some(PreviewType::VIDEO), + context_info: buffa::MessageField::some(wa::ContextInfo { mentioned_jid: vec![ "111111@s.whatsapp.net".to_string(), "222222@s.whatsapp.net".to_string(), @@ -902,21 +889,21 @@ mod tests { is_forwarded: Some(true), forwarding_score: Some(5), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }; - let ext = original.extended_text_message.as_ref().unwrap(); - let ctx = ext.context_info.as_ref().unwrap(); + let ext = original.extended_text_message.as_option().unwrap(); + let ctx = ext.context_info.as_option().unwrap(); assert_eq!(ctx.mentioned_jid.len(), 2); assert_eq!(ctx.group_mentions.len(), 1); let prepared = original.prepare_for_quote(); - let ext = prepared.extended_text_message.as_ref().unwrap(); - let ctx = ext.context_info.as_ref().unwrap(); + let ext = prepared.extended_text_message.as_option().unwrap(); + let ctx = ext.context_info.as_option().unwrap(); assert!( ctx.mentioned_jid.is_empty(), "mentioned_jid should be empty after prepare_for_quote" @@ -927,7 +914,7 @@ mod tests { ); assert!( - ctx.quoted_message.is_none(), + ctx.quoted_message.is_unset(), "quoted_message should be None after prepare_for_quote" ); assert!( @@ -949,8 +936,8 @@ mod tests { assert_eq!(ext.title.as_deref(), Some("Example Title")); assert_eq!(ext.text_argb, Some(0xFFFFFF)); assert_eq!(ext.background_argb, Some(0x000000)); - assert_eq!(ext.font(), FontType::SystemBold); - assert_eq!(ext.preview_type(), PreviewType::Video); + assert_eq!(ext.font, Some(FontType::SYSTEM_BOLD)); + assert_eq!(ext.preview_type, Some(PreviewType::VIDEO)); assert_eq!(ctx.is_forwarded, Some(true)); assert_eq!(ctx.forwarding_score, Some(5)); @@ -960,7 +947,7 @@ mod tests { #[test] fn test_prepare_for_quote_preserves_media_fields() { let original = wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage { + image_message: buffa::MessageField::some(wa::message::ImageMessage { url: Some("https://mmg.whatsapp.net/...".to_string()), mimetype: Some("image/jpeg".to_string()), caption: Some("Check out this image!".to_string()), @@ -970,19 +957,19 @@ mod tests { width: Some(1920), media_key: Some(vec![5, 6, 7, 8]), direct_path: Some("/v/t62.1234-5/...".to_string()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { mentioned_jid: vec!["someone@s.whatsapp.net".to_string()], ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }; let prepared = original.prepare_for_quote(); - let img = prepared.image_message.as_ref().unwrap(); - let ctx = img.context_info.as_ref().unwrap(); + let img = prepared.image_message.as_option().unwrap(); + let ctx = img.context_info.as_option().unwrap(); assert!(ctx.mentioned_jid.is_empty()); @@ -1001,33 +988,33 @@ mod tests { #[test] fn test_prepare_for_quote_breaks_quote_chain() { let original = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("This is a reply".to_string()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { stanza_id: Some("original-msg-id".to_string()), participant: Some("original-sender@s.whatsapp.net".to_string()), remote_jid: Some("chat@s.whatsapp.net".to_string()), - quoted_message: Some(Box::new(wa::Message { + quoted_message: buffa::MessageField::some(wa::Message { conversation: Some("The original message".to_string()), ..Default::default() - })), + }), mentioned_jid: vec!["user@s.whatsapp.net".to_string()], is_forwarded: Some(true), forwarding_score: Some(3), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }; let prepared = original.prepare_for_quote(); - let ext = prepared.extended_text_message.as_ref().unwrap(); - let ctx = ext.context_info.as_ref().unwrap(); + let ext = prepared.extended_text_message.as_option().unwrap(); + let ctx = ext.context_info.as_option().unwrap(); assert!( - ctx.quoted_message.is_none(), + ctx.quoted_message.is_unset(), "quoted_message should be None (quote chain broken)" ); assert!( @@ -1065,10 +1052,10 @@ mod tests { #[test] fn test_set_context_info_extended_text() { let mut msg = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("Reply text".to_string()), ..Default::default() - })), + }), ..Default::default() }; @@ -1080,8 +1067,8 @@ mod tests { assert!(msg.set_context_info(context)); - let ext = msg.extended_text_message.as_ref().unwrap(); - let ctx = ext.context_info.as_ref().unwrap(); + let ext = msg.extended_text_message.as_option().unwrap(); + let ctx = ext.context_info.as_option().unwrap(); assert_eq!(ctx.stanza_id.as_deref(), Some("test-id")); assert_eq!(ctx.participant.as_deref(), Some("sender@s.whatsapp.net")); } @@ -1090,10 +1077,10 @@ mod tests { #[test] fn test_set_context_info_image() { let mut msg = wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage { + image_message: buffa::MessageField::some(wa::message::ImageMessage { caption: Some("Image caption".to_string()), ..Default::default() - })), + }), ..Default::default() }; @@ -1104,10 +1091,10 @@ mod tests { assert!(msg.set_context_info(context)); - let img = msg.image_message.as_ref().unwrap(); - assert!(img.context_info.is_some()); + let img = msg.image_message.as_option().unwrap(); + assert!(img.context_info.is_set()); assert_eq!( - img.context_info.as_ref().unwrap().stanza_id.as_deref(), + img.context_info.as_option().unwrap().stanza_id.as_deref(), Some("img-id") ); } @@ -1116,12 +1103,12 @@ mod tests { #[test] fn test_set_context_info_location() { let mut msg = wa::Message { - location_message: Some(Box::new(wa::message::LocationMessage { + location_message: buffa::MessageField::some(wa::message::LocationMessage { degrees_latitude: Some(40.7128), degrees_longitude: Some(-74.0060), name: Some("New York".to_string()), ..Default::default() - })), + }), ..Default::default() }; @@ -1132,8 +1119,8 @@ mod tests { assert!(msg.set_context_info(context)); - let loc = msg.location_message.as_ref().unwrap(); - assert!(loc.context_info.is_some()); + let loc = msg.location_message.as_option().unwrap(); + assert!(loc.context_info.is_set()); } #[test] @@ -1152,11 +1139,12 @@ mod tests { assert!(msg.conversation.is_none(), "conversation must be moved out"); let ext = msg .extended_text_message + .as_option() .expect("promoted to extended_text_message"); assert_eq!(ext.text.as_deref(), Some("Simple text")); assert_eq!( ext.context_info - .as_ref() + .as_option() .and_then(|c| c.stanza_id.as_deref()), Some("test-id") ); @@ -1171,7 +1159,7 @@ mod tests { }; assert!(!msg.set_context_info(context)); assert!(msg.conversation.is_none()); - assert!(msg.extended_text_message.is_none()); + assert!(msg.extended_text_message.is_unset()); } /// Test: build_quote_context produces correct structure. @@ -1187,9 +1175,9 @@ mod tests { Some("1234567890@s.whatsapp.net") ); - let quoted = context.quoted_message.as_ref().unwrap(); - let ext = quoted.extended_text_message.as_ref().unwrap(); - let quoted_ctx = ext.context_info.as_ref().unwrap(); + let quoted = context.quoted_message.as_option().unwrap(); + let ext = quoted.extended_text_message.as_option().unwrap(); + let quoted_ctx = ext.context_info.as_option().unwrap(); assert!( quoted_ctx.mentioned_jid.is_empty(), "Quoted message mentions should be stripped" @@ -1200,9 +1188,9 @@ mod tests { #[test] fn test_prepare_for_quote_ephemeral() { let ephemeral_msg = wa::Message { - ephemeral_message: Some(Box::new(wa::message::FutureProofMessage { - message: Some(Box::new(create_message_with_mentions())), - })), + ephemeral_message: buffa::MessageField::some(wa::message::FutureProofMessage { + message: buffa::MessageField::some(create_message_with_mentions()), + }), ..Default::default() }; @@ -1210,13 +1198,13 @@ mod tests { let inner = prepared .ephemeral_message - .as_ref() + .as_option() .unwrap() .message - .as_ref() + .as_option() .unwrap(); - let ext = inner.extended_text_message.as_ref().unwrap(); - let ctx = ext.context_info.as_ref().unwrap(); + let ext = inner.extended_text_message.as_option().unwrap(); + let ctx = ext.context_info.as_option().unwrap(); assert!( ctx.mentioned_jid.is_empty(), @@ -1228,18 +1216,18 @@ mod tests { #[test] fn test_prepare_for_quote_view_once() { let view_once_msg = wa::Message { - view_once_message: Some(Box::new(wa::message::FutureProofMessage { - message: Some(Box::new(wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage { - context_info: Some(Box::new(wa::ContextInfo { + view_once_message: buffa::MessageField::some(wa::message::FutureProofMessage { + message: buffa::MessageField::some(wa::Message { + image_message: buffa::MessageField::some(wa::message::ImageMessage { + context_info: buffa::MessageField::some(wa::ContextInfo { mentioned_jid: vec!["someone@s.whatsapp.net".to_string()], ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() - })), - })), + }), + }), ..Default::default() }; @@ -1247,13 +1235,13 @@ mod tests { let inner = prepared .view_once_message - .as_ref() + .as_option() .unwrap() .message - .as_ref() + .as_option() .unwrap(); - let img = inner.image_message.as_ref().unwrap(); - let ctx = img.context_info.as_ref().unwrap(); + let img = inner.image_message.as_option().unwrap(); + let ctx = img.context_info.as_option().unwrap(); assert!( ctx.mentioned_jid.is_empty(), @@ -1265,37 +1253,39 @@ mod tests { #[test] fn test_prepare_for_quote_device_sent_message() { let device_sent_msg = wa::Message { - device_sent_message: Some(Box::new(wa::message::DeviceSentMessage { + device_sent_message: buffa::MessageField::some(wa::message::DeviceSentMessage { destination_jid: Some("1234567890@s.whatsapp.net".to_string()), - message: Some(Box::new(wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { - text: Some("Message from other device".to_string()), - context_info: Some(Box::new(wa::ContextInfo { - mentioned_jid: vec![ - "user1@s.whatsapp.net".to_string(), - "user2@s.whatsapp.net".to_string(), - ], - group_mentions: vec![wa::GroupMention { - group_jid: Some("group@g.us".to_string()), - group_subject: Some("Group Name".to_string()), - }], + message: buffa::MessageField::some(wa::Message { + extended_text_message: buffa::MessageField::some( + wa::message::ExtendedTextMessage { + text: Some("Message from other device".to_string()), + context_info: buffa::MessageField::some(wa::ContextInfo { + mentioned_jid: vec![ + "user1@s.whatsapp.net".to_string(), + "user2@s.whatsapp.net".to_string(), + ], + group_mentions: vec![wa::GroupMention { + group_jid: Some("group@g.us".to_string()), + group_subject: Some("Group Name".to_string()), + }], + ..Default::default() + }), ..Default::default() - })), - ..Default::default() - })), + }, + ), ..Default::default() - })), + }), phash: Some("somephash".to_string()), - })), + }), ..Default::default() }; let prepared = device_sent_msg.prepare_for_quote(); - let wrapper = prepared.device_sent_message.as_ref().unwrap(); - let inner = wrapper.message.as_ref().unwrap(); - let ext = inner.extended_text_message.as_ref().unwrap(); - let ctx = ext.context_info.as_ref().unwrap(); + let wrapper = prepared.device_sent_message.as_option().unwrap(); + let inner = wrapper.message.as_option().unwrap(); + let ext = inner.extended_text_message.as_option().unwrap(); + let ctx = ext.context_info.as_option().unwrap(); assert!( ctx.mentioned_jid.is_empty(), @@ -1318,23 +1308,25 @@ mod tests { #[test] fn test_prepare_for_quote_edited_message() { let edited_msg = wa::Message { - edited_message: Some(Box::new(wa::message::FutureProofMessage { - message: Some(Box::new(wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { - text: Some("Edited message text".to_string()), - context_info: Some(Box::new(wa::ContextInfo { - mentioned_jid: vec!["mentioned@s.whatsapp.net".to_string()], - group_mentions: vec![wa::GroupMention { - group_jid: Some("editedgroup@g.us".to_string()), - group_subject: Some("Edited Group".to_string()), - }], + edited_message: buffa::MessageField::some(wa::message::FutureProofMessage { + message: buffa::MessageField::some(wa::Message { + extended_text_message: buffa::MessageField::some( + wa::message::ExtendedTextMessage { + text: Some("Edited message text".to_string()), + context_info: buffa::MessageField::some(wa::ContextInfo { + mentioned_jid: vec!["mentioned@s.whatsapp.net".to_string()], + group_mentions: vec![wa::GroupMention { + group_jid: Some("editedgroup@g.us".to_string()), + group_subject: Some("Edited Group".to_string()), + }], + ..Default::default() + }), ..Default::default() - })), - ..Default::default() - })), + }, + ), ..Default::default() - })), - })), + }), + }), ..Default::default() }; @@ -1342,13 +1334,13 @@ mod tests { let inner = prepared .edited_message - .as_ref() + .as_option() .unwrap() .message - .as_ref() + .as_option() .unwrap(); - let ext = inner.extended_text_message.as_ref().unwrap(); - let ctx = ext.context_info.as_ref().unwrap(); + let ext = inner.extended_text_message.as_option().unwrap(); + let ctx = ext.context_info.as_option().unwrap(); assert!( ctx.mentioned_jid.is_empty(), @@ -1366,37 +1358,37 @@ mod tests { #[test] fn test_prepare_for_quote_nested_wrappers() { let nested_wrapper_msg = wa::Message { - device_sent_message: Some(Box::new(wa::message::DeviceSentMessage { + device_sent_message: buffa::MessageField::some(wa::message::DeviceSentMessage { destination_jid: Some("dest@s.whatsapp.net".to_string()), - message: Some(Box::new(wa::Message { - ephemeral_message: Some(Box::new(wa::message::FutureProofMessage { - message: Some(Box::new(wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage { + message: buffa::MessageField::some(wa::Message { + ephemeral_message: buffa::MessageField::some(wa::message::FutureProofMessage { + message: buffa::MessageField::some(wa::Message { + image_message: buffa::MessageField::some(wa::message::ImageMessage { caption: Some("Nested image".to_string()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { mentioned_jid: vec!["deep@s.whatsapp.net".to_string()], ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() - })), - })), + }), + }), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }; let prepared = nested_wrapper_msg.prepare_for_quote(); - let device_sent = prepared.device_sent_message.as_ref().unwrap(); - let device_inner = device_sent.message.as_ref().unwrap(); - let ephemeral = device_inner.ephemeral_message.as_ref().unwrap(); - let ephemeral_inner = ephemeral.message.as_ref().unwrap(); - let img = ephemeral_inner.image_message.as_ref().unwrap(); - let ctx = img.context_info.as_ref().unwrap(); + let device_sent = prepared.device_sent_message.as_option().unwrap(); + let device_inner = device_sent.message.as_option().unwrap(); + let ephemeral = device_inner.ephemeral_message.as_option().unwrap(); + let ephemeral_inner = ephemeral.message.as_option().unwrap(); + let img = ephemeral_inner.image_message.as_option().unwrap(); + let ctx = img.context_info.as_option().unwrap(); assert!( ctx.mentioned_jid.is_empty(), @@ -1411,27 +1403,27 @@ mod tests { fn test_set_context_info_various_types() { let test_cases: Vec = vec![ wa::Message { - video_message: Some(Box::default()), + video_message: buffa::MessageField::some(Default::default()), ..Default::default() }, wa::Message { - audio_message: Some(Box::default()), + audio_message: buffa::MessageField::some(Default::default()), ..Default::default() }, wa::Message { - document_message: Some(Box::default()), + document_message: buffa::MessageField::some(Default::default()), ..Default::default() }, wa::Message { - sticker_message: Some(Box::default()), + sticker_message: buffa::MessageField::some(Default::default()), ..Default::default() }, wa::Message { - contact_message: Some(Box::default()), + contact_message: buffa::MessageField::some(Default::default()), ..Default::default() }, wa::Message { - poll_creation_message: Some(Box::default()), + poll_creation_message: buffa::MessageField::some(Default::default()), ..Default::default() }, ]; @@ -1452,36 +1444,36 @@ mod tests { #[test] fn test_prepare_for_quote_preserves_bot_quote_chain() { let msg = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("Bot reply".to_string()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { // Bot JID - starts with 1313555 participant: Some("131355512345@s.whatsapp.net".to_string()), stanza_id: Some("bot-msg-id".to_string()), remote_jid: Some("chat@g.us".to_string()), - quoted_message: Some(Box::new(wa::Message { + quoted_message: buffa::MessageField::some(wa::Message { conversation: Some("Original user message".to_string()), ..Default::default() - })), + }), mentioned_jid: vec!["user@s.whatsapp.net".to_string()], ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }; let prepared = msg.prepare_for_quote(); let ctx = prepared .extended_text_message - .as_ref() + .as_option() .unwrap() .context_info - .as_ref() + .as_option() .unwrap(); assert!( - ctx.quoted_message.is_some(), + ctx.quoted_message.is_set(), "Bot quote chain should be preserved" ); assert!(ctx.stanza_id.is_some(), "Bot stanza_id should be preserved"); @@ -1504,34 +1496,34 @@ mod tests { #[test] fn test_prepare_for_quote_preserves_bot_server_quote_chain() { let msg = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("Bot reply".to_string()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { // Bot JID with @bot server participant: Some("mybot@bot".to_string()), stanza_id: Some("bot-msg-id".to_string()), - quoted_message: Some(Box::new(wa::Message { + quoted_message: buffa::MessageField::some(wa::Message { conversation: Some("Original".to_string()), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }; let prepared = msg.prepare_for_quote(); let ctx = prepared .extended_text_message - .as_ref() + .as_option() .unwrap() .context_info - .as_ref() + .as_option() .unwrap(); assert!( - ctx.quoted_message.is_some(), + ctx.quoted_message.is_set(), "Bot (@bot server) quote chain should be preserved" ); } @@ -1591,31 +1583,36 @@ mod tests { #[test] fn test_into_base_message_unwraps_device_sent_reaction() { let msg = wa::Message { - device_sent_message: Some(Box::new(wa::message::DeviceSentMessage { + device_sent_message: buffa::MessageField::some(wa::message::DeviceSentMessage { destination_jid: Some("5511999999999@s.whatsapp.net".to_string()), - message: Some(Box::new(wa::Message { - reaction_message: Some(Box::new(wa::message::ReactionMessage { + message: buffa::MessageField::some(wa::Message { + reaction_message: buffa::MessageField::some(wa::message::ReactionMessage { text: Some("\u{2764}".to_string()), ..Default::default() - })), + }), ..Default::default() - })), - phash: None, - })), + }), + ..Default::default() + }), ..Default::default() }; let unwrapped = msg.into_base_message(); assert!( - unwrapped.device_sent_message.is_none(), + unwrapped.device_sent_message.is_unset(), "device_sent_message wrapper should be removed" ); assert!( - unwrapped.reaction_message.is_some(), + unwrapped.reaction_message.is_set(), "reaction_message should be accessible after unwrapping" ); assert_eq!( - unwrapped.reaction_message.as_ref().unwrap().text.as_deref(), + unwrapped + .reaction_message + .as_option() + .unwrap() + .text + .as_deref(), Some("\u{2764}") ); } @@ -1624,19 +1621,19 @@ mod tests { #[test] fn test_into_base_message_unwraps_nested_dsm_ephemeral() { let msg = wa::Message { - device_sent_message: Some(Box::new(wa::message::DeviceSentMessage { + device_sent_message: buffa::MessageField::some(wa::message::DeviceSentMessage { destination_jid: Some("5511999999999@s.whatsapp.net".to_string()), - message: Some(Box::new(wa::Message { - ephemeral_message: Some(Box::new(wa::message::FutureProofMessage { - message: Some(Box::new(wa::Message { + message: buffa::MessageField::some(wa::Message { + ephemeral_message: buffa::MessageField::some(wa::message::FutureProofMessage { + message: buffa::MessageField::some(wa::Message { conversation: Some("secret".to_string()), ..Default::default() - })), - })), + }), + }), ..Default::default() - })), - phash: None, - })), + }), + ..Default::default() + }), ..Default::default() }; @@ -1664,18 +1661,17 @@ mod tests { #[test] fn test_into_base_message_empty_dsm() { let msg = wa::Message { - device_sent_message: Some(Box::new(wa::message::DeviceSentMessage { + device_sent_message: buffa::MessageField::some(wa::message::DeviceSentMessage { destination_jid: Some("5511999999999@s.whatsapp.net".to_string()), - message: None, - phash: None, - })), + ..Default::default() + }), ..Default::default() }; let unwrapped = msg.into_base_message(); // With no inner message the wrapper is preserved assert!( - unwrapped.device_sent_message.is_some(), + unwrapped.device_sent_message.is_set(), "empty DSM wrapper should be preserved" ); assert!(unwrapped.conversation.is_none()); @@ -1694,7 +1690,7 @@ mod tests { message_secret: Some(vec![1, 2, 3]), ..Default::default() }; - let result = merge_dsm_context(Some(Box::new(inner.clone())), None).unwrap(); + let result = merge_dsm_context(Some(inner.clone()), None).unwrap(); assert_eq!(result.message_secret, Some(vec![1, 2, 3])); } @@ -1702,7 +1698,13 @@ mod tests { fn test_merge_dsm_context_outer_only() { let outer = wa::MessageContextInfo { message_secret: Some(vec![4, 5, 6]), - limit_sharing_v2: Some(wa::LimitSharing::default()), + // Distinguishable payload: is_set() alone cannot tell outer's + // value apart from a default the merge might set on its own. + limit_sharing_v2: buffa::MessageField::some(wa::LimitSharing { + sharing_limited: Some(true), + limit_sharing_setting_timestamp: Some(12345), + ..Default::default() + }), ..Default::default() }; let result = merge_dsm_context(None, Some(&outer)).unwrap(); @@ -1711,10 +1713,12 @@ mod tests { Some(vec![4, 5, 6]), "message_secret should come from outer when inner is None" ); - assert!( - result.limit_sharing_v2.is_some(), - "limit_sharing_v2 should come from outer" - ); + let ls = result + .limit_sharing_v2 + .as_option() + .expect("limit_sharing_v2 should come from outer"); + assert_eq!(ls.sharing_limited, Some(true)); + assert_eq!(ls.limit_sharing_setting_timestamp, Some(12345)); } #[test] @@ -1741,7 +1745,7 @@ mod tests { message_secret: Some(vec![4, 5, 6]), ..Default::default() }; - let result = merge_dsm_context(Some(Box::new(inner)), Some(&outer)).unwrap(); + let result = merge_dsm_context(Some(inner), Some(&outer)).unwrap(); assert_eq!( result.message_secret, Some(vec![1, 2, 3]), @@ -1759,7 +1763,7 @@ mod tests { message_secret: Some(vec![4, 5, 6]), ..Default::default() }; - let result = merge_dsm_context(Some(Box::new(inner)), Some(&outer)).unwrap(); + let result = merge_dsm_context(Some(inner), Some(&outer)).unwrap(); assert_eq!( result.message_secret, Some(vec![4, 5, 6]), @@ -1776,28 +1780,27 @@ mod tests { ..Default::default() }; let inner = wa::MessageContextInfo { - limit_sharing_v2: Some(inner_ls), + limit_sharing_v2: buffa::MessageField::some(inner_ls), ..Default::default() }; let outer = wa::MessageContextInfo { - limit_sharing_v2: Some(outer_ls), + limit_sharing_v2: buffa::MessageField::some(outer_ls), ..Default::default() }; - let result = merge_dsm_context(Some(Box::new(inner)), Some(&outer)).unwrap(); - assert_eq!( - result.limit_sharing_v2, - Some(outer_ls), + let result = merge_dsm_context(Some(inner), Some(&outer)).unwrap(); + assert!( + result.limit_sharing_v2.is_set(), "limit_sharing_v2 should always come from outer" ); // When outer is None, inner's limit_sharing_v2 should be cleared let inner_with_ls = wa::MessageContextInfo { - limit_sharing_v2: Some(wa::LimitSharing::default()), + limit_sharing_v2: buffa::MessageField::some(wa::LimitSharing::default()), ..Default::default() }; - let result = merge_dsm_context(Some(Box::new(inner_with_ls)), None).unwrap(); - assert_eq!( - result.limit_sharing_v2, None, + let result = merge_dsm_context(Some(inner_with_ls), None).unwrap(); + assert!( + result.limit_sharing_v2.is_unset(), "limit_sharing_v2 should be cleared when outer is None" ); } @@ -1805,12 +1808,12 @@ mod tests { #[test] fn test_merge_dsm_context_thread_id_fallback() { let outer = wa::MessageContextInfo { - thread_id: vec![wa::ThreadId::default()], + thread_id: vec![wa::ThreadID::default()], ..Default::default() }; // Inner has empty thread_id → should fall back to outer let inner_empty = wa::MessageContextInfo::default(); - let result = merge_dsm_context(Some(Box::new(inner_empty)), Some(&outer)).unwrap(); + let result = merge_dsm_context(Some(inner_empty), Some(&outer)).unwrap(); assert_eq!( result.thread_id.len(), 1, @@ -1819,10 +1822,10 @@ mod tests { // Inner has non-empty thread_id → should keep inner let inner_filled = wa::MessageContextInfo { - thread_id: vec![wa::ThreadId::default(), wa::ThreadId::default()], + thread_id: vec![wa::ThreadID::default(), wa::ThreadID::default()], ..Default::default() }; - let result = merge_dsm_context(Some(Box::new(inner_filled)), Some(&outer)).unwrap(); + let result = merge_dsm_context(Some(inner_filled), Some(&outer)).unwrap(); assert_eq!( result.thread_id.len(), 2, @@ -1848,7 +1851,7 @@ mod tests { Some("551199887766@s.whatsapp.net") ); assert_eq!(ctx.remote_jid, None); - assert!(ctx.quoted_message.is_some()); + assert!(ctx.quoted_message.is_set()); assert!(ctx.mentioned_jid.is_empty()); } @@ -1979,8 +1982,14 @@ mod tests { let ctx = build_quote_context_with_info("msg-id", &sender, &group, &group, &msg); // The quoted message's nested context_info should have mentions stripped - let quoted = ctx.quoted_message.unwrap(); - let inner_ctx = quoted.extended_text_message.unwrap().context_info.unwrap(); + let quoted = ctx.quoted_message.into_option().unwrap(); + let inner_ctx = quoted + .extended_text_message + .into_option() + .unwrap() + .context_info + .into_option() + .unwrap(); assert!(inner_ctx.mentioned_jid.is_empty()); assert!(inner_ctx.group_mentions.is_empty()); // The outer context should have no mentions @@ -1992,34 +2001,37 @@ mod tests { remote_jid: Some("5511999999999@s.whatsapp.net".to_string()), from_me: Some(true), id: Some("PARENT_MSG_ID".to_string()), - participant: None, + ..Default::default() } } #[test] fn test_wrap_as_album_child_basic() { let inner = wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage { + image_message: buffa::MessageField::some(wa::message::ImageMessage { url: Some("https://mmg.whatsapp.net/test".to_string()), ..Default::default() - })), + }), ..Default::default() }; let wrapped = wrap_as_album_child(inner, sample_parent_key()); - let future_proof = wrapped.associated_child_message.as_ref().unwrap(); - let inner_msg = future_proof.message.as_ref().unwrap(); - assert!(inner_msg.image_message.is_some()); - assert!(inner_msg.message_context_info.is_none()); + let future_proof = wrapped.associated_child_message.as_option().unwrap(); + let inner_msg = future_proof.message.as_option().unwrap(); + assert!(inner_msg.image_message.is_set()); + assert!(inner_msg.message_context_info.is_unset()); - let ctx = wrapped.message_context_info.as_ref().unwrap(); - let assoc = ctx.message_association.as_ref().unwrap(); + let ctx = wrapped.message_context_info.as_option().unwrap(); + let assoc = ctx.message_association.as_option().unwrap(); assert_eq!( assoc.association_type, - Some(wa::message_association::AssociationType::MediaAlbum as i32) + Some(wa::message_association::AssociationType::MEDIA_ALBUM) + ); + assert_eq!( + assoc.parent_message_key.as_option(), + Some(&sample_parent_key()) ); - assert_eq!(assoc.parent_message_key, Some(sample_parent_key())); assert_eq!(assoc.message_index, None); } @@ -2027,38 +2039,38 @@ mod tests { fn test_wrap_as_album_child_lifts_existing_context() { let secret = vec![1u8; 32]; let inner = wa::Message { - video_message: Some(Box::new(wa::message::VideoMessage { + video_message: buffa::MessageField::some(wa::message::VideoMessage { url: Some("https://mmg.whatsapp.net/vid".to_string()), ..Default::default() - })), - message_context_info: Some(Box::new(wa::MessageContextInfo { + }), + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(secret.clone()), ..Default::default() - })), + }), ..Default::default() }; let wrapped = wrap_as_album_child(inner, sample_parent_key()); - let ctx = wrapped.message_context_info.as_ref().unwrap(); + let ctx = wrapped.message_context_info.as_option().unwrap(); assert_eq!(ctx.message_secret.as_deref(), Some(secret.as_slice())); - assert!(ctx.message_association.is_some()); + assert!(ctx.message_association.is_set()); } #[test] fn is_view_once_detects_legacy_wrapper() { let msg = wa::Message { - view_once_message: Some(Box::new(wa::message::FutureProofMessage { - message: Some(Box::new(wa::Message::default())), - })), + view_once_message: buffa::MessageField::some(wa::message::FutureProofMessage { + message: buffa::MessageField::some(wa::Message::default()), + }), ..Default::default() }; assert!(msg.is_view_once()); let msg_v2 = wa::Message { - view_once_message_v2: Some(Box::new(wa::message::FutureProofMessage { - message: Some(Box::new(wa::Message::default())), - })), + view_once_message_v2: buffa::MessageField::some(wa::message::FutureProofMessage { + message: buffa::MessageField::some(wa::Message::default()), + }), ..Default::default() }; assert!(msg_v2.is_view_once()); @@ -2067,15 +2079,17 @@ mod tests { #[test] fn is_view_once_detects_wrapper_nested_in_device_sent() { let msg = wa::Message { - device_sent_message: Some(Box::new(wa::message::DeviceSentMessage { - message: Some(Box::new(wa::Message { - view_once_message_v2: Some(Box::new(wa::message::FutureProofMessage { - message: Some(Box::new(wa::Message::default())), - })), + device_sent_message: buffa::MessageField::some(wa::message::DeviceSentMessage { + message: buffa::MessageField::some(wa::Message { + view_once_message_v2: buffa::MessageField::some( + wa::message::FutureProofMessage { + message: buffa::MessageField::some(wa::Message::default()), + }, + ), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }; assert!(msg.is_view_once()); @@ -2084,10 +2098,10 @@ mod tests { #[test] fn is_view_once_detects_inline_image_flag() { let msg = wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage { + image_message: buffa::MessageField::some(wa::message::ImageMessage { view_once: Some(true), ..Default::default() - })), + }), ..Default::default() }; assert!(msg.is_view_once()); @@ -2096,10 +2110,10 @@ mod tests { #[test] fn is_view_once_detects_inline_video_flag() { let msg = wa::Message { - video_message: Some(Box::new(wa::message::VideoMessage { + video_message: buffa::MessageField::some(wa::message::VideoMessage { view_once: Some(true), ..Default::default() - })), + }), ..Default::default() }; assert!(msg.is_view_once()); @@ -2108,10 +2122,10 @@ mod tests { #[test] fn is_view_once_detects_inline_audio_flag() { let msg = wa::Message { - audio_message: Some(Box::new(wa::message::AudioMessage { + audio_message: buffa::MessageField::some(wa::message::AudioMessage { view_once: Some(true), ..Default::default() - })), + }), ..Default::default() }; assert!(msg.is_view_once()); @@ -2120,10 +2134,10 @@ mod tests { #[test] fn is_view_once_detects_inline_extended_text_flag() { let msg = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { view_once: Some(true), ..Default::default() - })), + }), ..Default::default() }; assert!(msg.is_view_once()); @@ -2132,16 +2146,16 @@ mod tests { #[test] fn is_view_once_detects_inline_flag_through_device_sent() { let msg = wa::Message { - device_sent_message: Some(Box::new(wa::message::DeviceSentMessage { - message: Some(Box::new(wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage { + device_sent_message: buffa::MessageField::some(wa::message::DeviceSentMessage { + message: buffa::MessageField::some(wa::Message { + image_message: buffa::MessageField::some(wa::message::ImageMessage { view_once: Some(true), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }; assert!(msg.is_view_once()); @@ -2150,16 +2164,16 @@ mod tests { #[test] fn is_view_once_false_for_plain_image() { let msg = wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage::default())), + image_message: buffa::MessageField::some(wa::message::ImageMessage::default()), ..Default::default() }; assert!(!msg.is_view_once()); let msg_explicit_false = wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage { + image_message: buffa::MessageField::some(wa::message::ImageMessage { view_once: Some(false), ..Default::default() - })), + }), ..Default::default() }; assert!(!msg_explicit_false.is_view_once()); @@ -2173,9 +2187,11 @@ mod tests { #[test] fn is_view_once_detects_v2_extension_wrapper() { let msg = wa::Message { - view_once_message_v2_extension: Some(Box::new(wa::message::FutureProofMessage { - message: Some(Box::new(wa::Message::default())), - })), + view_once_message_v2_extension: buffa::MessageField::some( + wa::message::FutureProofMessage { + message: buffa::MessageField::some(wa::Message::default()), + }, + ), ..Default::default() }; assert!(msg.is_view_once()); @@ -2189,10 +2205,10 @@ mod tests { }; assert!(msg.set_ephemeral_expiration(86400)); assert!(msg.conversation.is_none()); - let ext = msg.extended_text_message.unwrap(); + let ext = msg.extended_text_message.as_option().unwrap(); assert_eq!(ext.text.as_deref(), Some("hello")); assert_eq!( - ext.context_info.as_ref().and_then(|c| c.expiration), + ext.context_info.as_option().and_then(|c| c.expiration), Some(86400) ); } @@ -2202,26 +2218,30 @@ mod tests { let mut msg = wa::Message::default(); assert!(!msg.set_ephemeral_expiration(60)); assert!(msg.conversation.is_none()); - assert!(msg.extended_text_message.is_none()); + assert!(msg.extended_text_message.is_unset()); } #[test] fn is_view_once_detects_ephemeral_device_sent_view_once() { let msg = wa::Message { - ephemeral_message: Some(Box::new(wa::message::FutureProofMessage { - message: Some(Box::new(wa::Message { - device_sent_message: Some(Box::new(wa::message::DeviceSentMessage { - message: Some(Box::new(wa::Message { - view_once_message_v2: Some(Box::new(wa::message::FutureProofMessage { - message: Some(Box::new(wa::Message::default())), - })), + ephemeral_message: buffa::MessageField::some(wa::message::FutureProofMessage { + message: buffa::MessageField::some(wa::Message { + device_sent_message: buffa::MessageField::some( + wa::message::DeviceSentMessage { + message: buffa::MessageField::some(wa::Message { + view_once_message_v2: buffa::MessageField::some( + wa::message::FutureProofMessage { + message: buffa::MessageField::some(wa::Message::default()), + }, + ), + ..Default::default() + }), ..Default::default() - })), - ..Default::default() - })), + }, + ), ..Default::default() - })), - })), + }), + }), ..Default::default() }; assert!(msg.is_view_once()); @@ -2230,17 +2250,17 @@ mod tests { #[test] fn mentions_any_bot_true_for_bot_jid_in_extended_text() { let msg = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("@MetaAI hi".into()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { mentioned_jid: vec![ "5511999998888@s.whatsapp.net".into(), "867051314767696@bot".into(), ], ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }; assert!(msg.mentions_any_bot()); @@ -2251,14 +2271,14 @@ mod tests { // `Jid::is_bot()` also matches the legacy PN-form Meta bot; the old // `@bot`-only string split would have missed this. let msg = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("@MetaAI".into()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { mentioned_jid: vec!["13135550002@s.whatsapp.net".into()], ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }; assert!(msg.mentions_any_bot()); @@ -2267,17 +2287,17 @@ mod tests { #[test] fn mentions_any_bot_false_without_bot_jid() { let msg = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("hi friends".into()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { mentioned_jid: vec![ "5511999998888@s.whatsapp.net".into(), "120363021033254949@g.us".into(), ], ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }; assert!(!msg.mentions_any_bot()); @@ -2295,21 +2315,23 @@ mod tests { #[test] fn mentions_any_bot_sees_through_device_sent_wrapper() { let msg = wa::Message { - device_sent_message: Some(Box::new(wa::message::DeviceSentMessage { + device_sent_message: buffa::MessageField::some(wa::message::DeviceSentMessage { destination_jid: Some("867051314767696@bot".into()), - message: Some(Box::new(wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { - text: Some("@MetaAI".into()), - context_info: Some(Box::new(wa::ContextInfo { - mentioned_jid: vec!["867051314767696@bot".into()], + message: buffa::MessageField::some(wa::Message { + extended_text_message: buffa::MessageField::some( + wa::message::ExtendedTextMessage { + text: Some("@MetaAI".into()), + context_info: buffa::MessageField::some(wa::ContextInfo { + mentioned_jid: vec!["867051314767696@bot".into()], + ..Default::default() + }), ..Default::default() - })), - ..Default::default() - })), + }, + ), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }; assert!( @@ -2321,14 +2343,14 @@ mod tests { #[test] fn is_forwarded_true_and_false() { let fwd = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("fwd".into()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { is_forwarded: Some(true), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }; assert!(fwd.is_forwarded()); @@ -2340,11 +2362,11 @@ mod tests { assert!(!plain.is_forwarded()); let not_fwd = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("hi".into()), - context_info: Some(Box::new(wa::ContextInfo::default())), + context_info: buffa::MessageField::some(wa::ContextInfo::default()), ..Default::default() - })), + }), ..Default::default() }; assert!(!not_fwd.is_forwarded()); @@ -2361,10 +2383,10 @@ mod tests { // can attach. let etm = fwd .extended_text_message - .as_ref() + .as_option() .expect("conversation promoted to extended_text_message"); assert_eq!(etm.text.as_deref(), Some("hello")); - let ctx = etm.context_info.as_ref().expect("context_info present"); + let ctx = etm.context_info.as_option().expect("context_info present"); assert_eq!(ctx.is_forwarded, Some(true)); assert_eq!(ctx.forwarding_score, Some(0)); } @@ -2372,24 +2394,24 @@ mod tests { #[test] fn prepare_for_forward_bumps_score_when_source_already_forwarded() { let msg = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("hi".into()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { is_forwarded: Some(true), forwarding_score: Some(0), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }; let fwd = msg.prepare_for_forward(); let ctx = fwd .extended_text_message - .as_ref() + .as_option() .unwrap() .context_info - .as_ref() + .as_option() .unwrap(); assert_eq!(ctx.is_forwarded, Some(true)); // n = score(0) + already_forwarded(1) = 1. @@ -2399,25 +2421,25 @@ mod tests { #[test] fn prepare_for_forward_jumps_to_sentinel_at_threshold() { let msg = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("hi".into()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { is_forwarded: Some(true), // n = 4 + 1 = 5 -> frequently-forwarded sentinel, not 5. forwarding_score: Some(4), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }; let fwd = msg.prepare_for_forward(); let ctx = fwd .extended_text_message - .as_ref() + .as_option() .unwrap() .context_info - .as_ref() + .as_option() .unwrap(); assert_eq!(ctx.forwarding_score, Some(127)); } @@ -2425,32 +2447,32 @@ mod tests { #[test] fn prepare_for_forward_strips_quote_chain() { let msg = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("reply".into()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { stanza_id: Some("QUOTED".into()), participant: Some("123@s.whatsapp.net".into()), - quoted_message: Some(Box::new(wa::Message { + quoted_message: buffa::MessageField::some(wa::Message { conversation: Some("orig".into()), ..Default::default() - })), + }), mentioned_jid: vec!["456@s.whatsapp.net".into()], ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }; let fwd = msg.prepare_for_forward(); let ctx = fwd .extended_text_message - .as_ref() + .as_option() .unwrap() .context_info - .as_ref() + .as_option() .unwrap(); assert!(ctx.stanza_id.is_none()); - assert!(ctx.quoted_message.is_none()); + assert!(ctx.quoted_message.is_unset()); assert!(ctx.participant.is_none()); assert!(ctx.mentioned_jid.is_empty()); assert_eq!(ctx.is_forwarded, Some(true)); @@ -2461,30 +2483,30 @@ mod tests { // Quote sanitizing keeps the chain for bot participants, but forwarding // must always break it. let msg = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("reply to bot".into()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { stanza_id: Some("Q".into()), participant: Some("mybot@bot".into()), - quoted_message: Some(Box::new(wa::Message { + quoted_message: buffa::MessageField::some(wa::Message { conversation: Some("bot msg".into()), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }; let fwd = msg.prepare_for_forward(); let ctx = fwd .extended_text_message - .as_ref() + .as_option() .unwrap() .context_info - .as_ref() + .as_option() .unwrap(); - assert!(ctx.quoted_message.is_none()); + assert!(ctx.quoted_message.is_unset()); assert!(ctx.participant.is_none()); assert!(ctx.stanza_id.is_none()); } @@ -2496,9 +2518,11 @@ mod tests { ..Default::default() }; let wrapped = wa::Message { - view_once_message_v2_extension: Some(Box::new(wa::message::FutureProofMessage { - message: Some(Box::new(inner)), - })), + view_once_message_v2_extension: buffa::MessageField::some( + wa::message::FutureProofMessage { + message: buffa::MessageField::some(inner), + }, + ), ..Default::default() }; assert_eq!( @@ -2517,12 +2541,12 @@ mod tests { let msg = build_keep_in_chat_message(key, true, 12345); let k = msg .keep_in_chat_message - .as_ref() + .as_option() .expect("keep_in_chat_message set"); - assert_eq!(k.keep_type, Some(wa::KeepType::KeepForAll as i32)); + assert_eq!(k.keep_type, Some(wa::KeepType::KeepForAll)); assert_eq!(k.timestamp_ms, Some(12345)); assert_eq!( - k.key.as_ref().and_then(|key| key.id.as_deref()), + k.key.as_option().and_then(|key| key.id.as_deref()), Some("MID") ); } @@ -2532,16 +2556,16 @@ mod tests { // ptv (video note) is a send-supported context-info carrier; forwarding // it must still attach the forwarded marker. let msg = wa::Message { - ptv_message: Some(Box::new(wa::message::VideoMessage::default())), + ptv_message: buffa::MessageField::some(wa::message::VideoMessage::default()), ..Default::default() }; let fwd = msg.prepare_for_forward(); let ctx = fwd .ptv_message - .as_ref() + .as_option() .expect("ptv preserved") .context_info - .as_ref() + .as_option() .expect("context_info attached"); assert_eq!(ctx.is_forwarded, Some(true)); assert_eq!(ctx.forwarding_score, Some(0)); @@ -2552,7 +2576,7 @@ mod tests { let msg = build_keep_in_chat_message(wa::MessageKey::default(), false, 1); assert_eq!( msg.keep_in_chat_message.unwrap().keep_type, - Some(wa::KeepType::UndoKeepForAll as i32) + Some(wa::KeepType::UndoKeepForAll) ); } @@ -2573,9 +2597,9 @@ mod tests { let react = msg .reaction_message - .as_ref() + .as_option() .expect("reaction_message must be set"); - assert_eq!(react.key.as_ref(), Some(&key)); + assert_eq!(react.key.as_option(), Some(&key)); assert_eq!(react.text.as_deref(), Some("👍")); assert_eq!(react.sender_timestamp_ms, Some(ts)); // Only the reaction field is populated. @@ -2589,7 +2613,8 @@ mod tests { let msg = build_reaction_message(key, "❤️", 1); let participant = msg .reaction_message - .and_then(|r| r.key) + .into_option() + .and_then(|r| r.key.into_option()) .and_then(|k| k.participant); assert_eq!(participant.as_deref(), Some("15551230000@s.whatsapp.net")); } @@ -2601,7 +2626,7 @@ mod tests { let msg = build_reaction_message(group_target_key(), "", 1); let text = msg .reaction_message - .as_ref() + .as_option() .and_then(|r| r.text.as_deref()); assert_eq!(text, Some("")); } diff --git a/wacore/src/reaction.rs b/wacore/src/reaction.rs index 24b7b3e5f..62fde4de2 100644 --- a/wacore/src/reaction.rs +++ b/wacore/src/reaction.rs @@ -8,7 +8,7 @@ //! the HKDF use-case is `"Enc Reaction"` with empty AAD. use anyhow::{Result, ensure}; -use prost::Message; +use buffa::Message; use waproto::whatsapp::message::ReactionMessage; use crate::secret_enc_addon::{AddonContext, ModificationType, decrypt_addon, encrypt_addon}; @@ -83,7 +83,7 @@ pub fn decrypt_reaction_with_secret( message_secret, &reaction_addon_ctx(parent_msg_id, parent_sender_jid, reactor_jid), )?; - Ok(ReactionMessage::decode(&plaintext[..])?) + Ok(ReactionMessage::decode_from_slice(&plaintext[..])?) } #[cfg(test)] @@ -110,7 +110,7 @@ mod tests { decrypt_reaction_with_secret(&enc, &iv, &SECRET, PARENT_ID, AUTHOR, REACTOR).unwrap(); assert_eq!(out.text.as_deref(), Some("\u{1F525}")); assert_eq!(out.sender_timestamp_ms, Some(1_700_000_000_123)); - assert!(out.key.is_none(), "key must not travel in the plaintext"); + assert!(out.key.is_unset(), "key must not travel in the plaintext"); assert!(out.grouping_key.is_none()); } diff --git a/wacore/src/reporting_token.rs b/wacore/src/reporting_token.rs index d0b67fb4d..f9129c99d 100644 --- a/wacore/src/reporting_token.rs +++ b/wacore/src/reporting_token.rs @@ -185,7 +185,7 @@ static GROUP_INVITE_MESSAGE_SUBFIELDS: &[ReportingField] = &[ ReportingField::with_subfields(7, CONTEXT_INFO_SUBFIELDS), // contextInfo (at field 7 here) ]; -/// PollOption subfields +/// `Option` (poll option) subfields static POLL_OPTION_SUBFIELDS: &[ReportingField] = &[ ReportingField::new(1), // optionName ReportingField::new(2), // optionValue @@ -517,10 +517,10 @@ pub fn extract_reporting_token_content( /// Check if reporting token should be included for this message type. pub fn should_include_reporting_token(message: &wa::Message) -> bool { - message.reaction_message.is_none() - && message.enc_reaction_message.is_none() - && message.poll_update_message.is_none() - && message.keep_in_chat_message.is_none() + message.reaction_message.is_unset() + && message.enc_reaction_message.is_unset() + && message.poll_update_message.is_unset() + && message.keep_in_chat_message.is_unset() } /// Generate reporting token content by extracting whitelisted protobuf fields. @@ -650,7 +650,7 @@ pub fn prepare_message_with_context( let mut context_info = new_message.message_context_info.take().unwrap_or_default(); context_info.message_secret = Some(message_secret.to_vec()); context_info.reporting_token_version = Some(REPORTING_TOKEN_VERSION); - new_message.message_context_info = Some(context_info); + new_message.message_context_info = buffa::MessageField::some(context_info); new_message } @@ -671,14 +671,13 @@ pub fn reporting_context_info(result: &ReportingTokenResult) -> wa::MessageConte pub fn extract_message_secret(message: &wa::Message) -> Option<&[u8]> { message .message_context_info - .as_ref() + .as_option() .and_then(|ctx| ctx.message_secret.as_deref()) } #[cfg(test)] mod tests { use super::*; - use prost::Message; #[test] fn test_generate_message_secret() { @@ -700,10 +699,10 @@ mod tests { let secret = [0x42u8; MESSAGE_SECRET_SIZE]; let msg = wa::Message { conversation: Some("hi".into()), - message_context_info: Some(Box::new(wa::MessageContextInfo { + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(secret.to_vec()), ..Default::default() - })), + }), ..Default::default() }; let to: Jid = "5511999999999@s.whatsapp.net".parse().unwrap(); @@ -821,10 +820,10 @@ mod tests { #[test] fn test_generate_reporting_token_content_extended_text() { let message = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("Extended text message".to_string()), ..Default::default() - })), + }), ..Default::default() }; @@ -851,18 +850,19 @@ mod tests { // Reaction message should NOT include token let reaction_message = wa::Message { - reaction_message: Some(Box::new(wa::message::ReactionMessage { - key: None, - text: Some("👍".to_string()), + reaction_message: buffa::MessageField::some(wa::message::ReactionMessage { + text: Some("\u{1f44d}".to_string()), ..Default::default() - })), + }), ..Default::default() }; assert!(!should_include_reporting_token(&reaction_message)); // Poll update should NOT include token let poll_update = wa::Message { - poll_update_message: Some(Box::default()), + poll_update_message: buffa::MessageField::some( + wa::message::PollUpdateMessage::default(), + ), ..Default::default() }; assert!(!should_include_reporting_token(&poll_update)); @@ -876,7 +876,7 @@ mod tests { ..Default::default() }; - let message_bytes = message.encode_to_vec(); + let message_bytes = waproto::codec::message_to_vec(&message); let extracted = extract_reporting_token_content(&message_bytes, REPORTING_FIELDS); assert!(extracted.is_some()); @@ -891,15 +891,15 @@ mod tests { fn test_extract_filters_non_whitelisted_fields() { // Create an extended text message with contextInfo that has non-whitelisted fields let message = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("Hello".to_string()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { stanza_id: Some("should-be-excluded".to_string()), // Field 1 - NOT in whitelist is_forwarded: Some(true), // Field 22 - in whitelist ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }; @@ -1002,10 +1002,10 @@ mod tests { // Excluded type (reaction): both paths bail before extraction/secret/key (the // reorder makes that skip explicit) and return None. let reaction = wa::Message { - reaction_message: Some(Box::new(wa::message::ReactionMessage { + reaction_message: buffa::MessageField::some(wa::message::ReactionMessage { text: Some("👍".to_string()), ..Default::default() - })), + }), ..Default::default() }; let reaction_encoded = waproto::codec::message_to_vec(&reaction); @@ -1084,21 +1084,28 @@ mod tests { let secret = [0x42u8; MESSAGE_SECRET_SIZE]; let prepared = prepare_message_with_context(&message, &secret); - let ctx = prepared - .message_context_info - .expect("prepared message should have context info"); - assert_eq!(ctx.message_secret, Some(secret.to_vec())); - assert_eq!(ctx.reporting_token_version, Some(REPORTING_TOKEN_VERSION)); + assert!( + prepared.message_context_info.is_set(), + "prepared message should have context info" + ); + assert_eq!( + prepared.message_context_info.message_secret, + Some(secret.to_vec()) + ); + assert_eq!( + prepared.message_context_info.reporting_token_version, + Some(REPORTING_TOKEN_VERSION) + ); } #[test] fn test_extract_message_secret() { let secret = vec![0x55u8; MESSAGE_SECRET_SIZE]; let message = wa::Message { - message_context_info: Some(Box::new(wa::MessageContextInfo { + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { message_secret: Some(secret.clone()), ..Default::default() - })), + }), ..Default::default() }; @@ -1198,10 +1205,10 @@ mod tests { fn test_golden_extended_text_content_extraction() { // Golden test: extended text message content extraction let message = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("Hi".to_string()), ..Default::default() - })), + }), ..Default::default() }; @@ -1263,17 +1270,17 @@ mod tests { fn test_context_info_filtering_only_extracts_whitelisted() { // Verify that contextInfo only extracts fields 21 (forwardingScore) and 22 (isForwarded) let message = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("Test".to_string()), - context_info: Some(Box::new(wa::ContextInfo { + context_info: buffa::MessageField::some(wa::ContextInfo { stanza_id: Some("SHOULD_BE_EXCLUDED".to_string()), // Field 1 participant: Some("ALSO_EXCLUDED".to_string()), // Field 2 is_forwarded: Some(true), // Field 22 - INCLUDED forwarding_score: Some(5), // Field 21 - INCLUDED ..Default::default() - })), + }), ..Default::default() - })), + }), ..Default::default() }; @@ -1356,11 +1363,11 @@ mod tests { fn test_extraction_handles_empty_nested_message() { // An extended text message with empty contextInfo should still extract the text let message = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("Content".to_string()), - context_info: Some(Box::new(wa::ContextInfo::default())), // Empty + context_info: buffa::MessageField::some(wa::ContextInfo::default()), // Empty ..Default::default() - })), + }), ..Default::default() }; @@ -1433,29 +1440,35 @@ mod tests { // Verify all excluded message types return None/false let reaction = wa::Message { - reaction_message: Some(Box::new(wa::message::ReactionMessage { - text: Some("👍".to_string()), + reaction_message: buffa::MessageField::some(wa::message::ReactionMessage { + text: Some("\u{1f44d}".to_string()), ..Default::default() - })), + }), ..Default::default() }; assert!(!should_include_reporting_token(&reaction)); assert!(generate_reporting_token_content(&reaction).is_none()); let enc_reaction = wa::Message { - enc_reaction_message: Some(Box::default()), + enc_reaction_message: buffa::MessageField::some( + wa::message::EncReactionMessage::default(), + ), ..Default::default() }; assert!(!should_include_reporting_token(&enc_reaction)); let poll_update = wa::Message { - poll_update_message: Some(Box::default()), + poll_update_message: buffa::MessageField::some( + wa::message::PollUpdateMessage::default(), + ), ..Default::default() }; assert!(!should_include_reporting_token(&poll_update)); let keep_in_chat = wa::Message { - keep_in_chat_message: Some(Box::default()), + keep_in_chat_message: buffa::MessageField::some( + wa::message::KeepInChatMessage::default(), + ), ..Default::default() }; assert!(!should_include_reporting_token(&keep_in_chat)); @@ -1491,7 +1504,7 @@ mod tests { // MessageContextInfo added with correct values let ctx = prepared .message_context_info - .as_ref() + .as_option() .expect("prepared message should have context info"); assert_eq!( ctx.message_secret @@ -1507,10 +1520,10 @@ mod tests { // If message already has MessageContextInfo, we should update it, not replace let original = wa::Message { conversation: Some("Test".to_string()), - message_context_info: Some(Box::new(wa::MessageContextInfo { + message_context_info: buffa::MessageField::some(wa::MessageContextInfo { device_list_metadata_version: Some(42), // Some existing field ..Default::default() - })), + }), ..Default::default() }; @@ -1519,7 +1532,7 @@ mod tests { let ctx = prepared .message_context_info - .as_ref() + .as_option() .expect("prepared message should have existing context info preserved"); assert_eq!( ctx.message_secret diff --git a/wacore/src/send.rs b/wacore/src/send.rs index 8a14e9432..a88ec76f6 100644 --- a/wacore/src/send.rs +++ b/wacore/src/send.rs @@ -15,8 +15,8 @@ use crate::types::jid::JidExt; use crate::types::jid::make_sender_key_name; use crate::types::message::PeerMessageOptions; use anyhow::{Result, anyhow, bail}; +use buffa::Message as ProtoMessage; use futures::stream::{FuturesUnordered, StreamExt}; -use prost::Message as ProtoMessage; use rand::{CryptoRng, Rng}; use std::collections::HashSet; use std::future::Future; diff --git a/wacore/src/send/classify.rs b/wacore/src/send/classify.rs index 3668db87b..6ad28ddba 100644 --- a/wacore/src/send/classify.rs +++ b/wacore/src/send/classify.rs @@ -22,8 +22,8 @@ pub(crate) fn unwrap_message(msg: &wa::Message) -> &wa::Message { macro_rules! try_unwrap { ($($field:ident),+ $(,)?) => { $( - if let Some(ref w) = msg.$field { - if let Some(ref inner) = w.message { + if let Some(w) = msg.$field.as_option() { + if let Some(inner) = w.message.as_option() { return unwrap_message(inner); } } @@ -57,8 +57,8 @@ pub(crate) fn unwrap_message(msg: &wa::Message) -> &wa::Message { newsletter_admin_profile_message_v2, poll_creation_message_v4, ); - if let Some(ref dsm) = msg.device_sent_message - && let Some(ref inner) = dsm.message + if let Some(dsm) = msg.device_sent_message.as_option() + && let Some(inner) = dsm.message.as_option() { return unwrap_message(inner); } @@ -69,61 +69,61 @@ pub(crate) fn unwrap_message(msg: &wa::Message) -> &wa::Message { pub fn stanza_type_from_message(msg: &wa::Message) -> &'static str { let msg = unwrap_message(msg); - if msg.reaction_message.is_some() || msg.enc_reaction_message.is_some() { + if msg.reaction_message.is_set() || msg.enc_reaction_message.is_set() { return stanza::MSG_TYPE_REACTION; } - if msg.event_message.is_some() || msg.enc_event_response_message.is_some() { + if msg.event_message.is_set() || msg.enc_event_response_message.is_set() { return stanza::MSG_TYPE_EVENT; } - if let Some(ref sec) = msg.secret_encrypted_message { + if let Some(sec) = msg.secret_encrypted_message.as_option() { use wa::message::secret_encrypted_message::SecretEncType; - match SecretEncType::try_from(sec.secret_enc_type.unwrap_or(0)) { - Ok(SecretEncType::EventEdit) => return stanza::MSG_TYPE_EVENT, - Ok(SecretEncType::MessageEdit) => return stanza::MSG_TYPE_TEXT, - Ok(SecretEncType::PollEdit | SecretEncType::PollAddOption) => { + match sec.secret_enc_type { + Some(SecretEncType::EventEdit) => return stanza::MSG_TYPE_EVENT, + Some(SecretEncType::MessageEdit) => return stanza::MSG_TYPE_TEXT, + Some(SecretEncType::PollEdit | SecretEncType::PollAddOption) => { return stanza::MSG_TYPE_POLL; } _ => {} } } - if msg.poll_creation_message.is_some() - || msg.poll_creation_message_v2.is_some() - || msg.poll_creation_message_v3.is_some() - || msg.poll_creation_message_v5.is_some() - || msg.poll_update_message.is_some() + if msg.poll_creation_message.is_set() + || msg.poll_creation_message_v2.is_set() + || msg.poll_creation_message_v3.is_set() + || msg.poll_creation_message_v5.is_set() + || msg.poll_update_message.is_set() { return stanza::MSG_TYPE_POLL; } if msg.conversation.is_some() - || msg.protocol_message.is_some() - || msg.keep_in_chat_message.is_some() - || msg.edited_message.is_some() - || msg.pin_in_chat_message.is_some() - || msg.interactive_message.is_some() - || msg.template_button_reply_message.is_some() - || msg.request_phone_number_message.is_some() - || msg.enc_comment_message.is_some() - || msg.newsletter_admin_invite_message.is_some() - || msg.newsletter_follower_invite_message_v2.is_some() - || msg.message_history_notice.is_some() - || msg.album_message.is_some() + || msg.protocol_message.is_set() + || msg.keep_in_chat_message.is_set() + || msg.edited_message.is_set() + || msg.pin_in_chat_message.is_set() + || msg.interactive_message.is_set() + || msg.template_button_reply_message.is_set() + || msg.request_phone_number_message.is_set() + || msg.enc_comment_message.is_set() + || msg.newsletter_admin_invite_message.is_set() + || msg.newsletter_follower_invite_message_v2.is_set() + || msg.message_history_notice.is_set() + || msg.album_message.is_set() // Payment family. WA Web's typeAttributeFromProtobuf leaves these at the media // default, but media-without-mediatype is dropped by the server (so is a bare // "pay" stanza); text is what delivers and renders on Android. - || msg.request_payment_message.is_some() - || msg.send_payment_message.is_some() - || msg.payment_invite_message.is_some() - || msg.decline_payment_request_message.is_some() - || msg.cancel_payment_request_message.is_some() + || msg.request_payment_message.is_set() + || msg.send_payment_message.is_set() + || msg.payment_invite_message.is_set() + || msg.decline_payment_request_message.is_set() + || msg.cancel_payment_request_message.is_set() { return stanza::MSG_TYPE_TEXT; } // pollResultSnapshotMessage maps to "text" by default in WA Web // (gated behind isPollResultSnapshotPollTypeEnvelopeEnabled for "poll") - if msg.poll_result_snapshot_message.is_some() || msg.poll_result_snapshot_message_v3.is_some() { + if msg.poll_result_snapshot_message.is_set() || msg.poll_result_snapshot_message_v3.is_set() { return stanza::MSG_TYPE_TEXT; } - if let Some(ref ext) = msg.extended_text_message { + if let Some(ext) = msg.extended_text_message.as_option() { if ext .matched_text .as_ref() @@ -143,10 +143,9 @@ pub fn peer_message_options_from_message(msg: &wa::Message) -> PeerMessageOption // not model; use the default-on wire shape for supported peer PDO flows. let request_type = unwrap_message(msg) .protocol_message - .as_deref() - .and_then(|pm| pm.peer_data_operation_request_message.as_ref()) - .and_then(|pdo| pdo.peer_data_operation_request_type) - .and_then(|raw| PdoType::try_from(raw).ok()); + .as_option() + .and_then(|pm| pm.peer_data_operation_request_message.as_option()) + .and_then(|pdo| pdo.peer_data_operation_request_type); match request_type { Some(PdoType::HistorySyncOnDemand) => PeerMessageOptions::high_force_on_demand(), @@ -165,58 +164,58 @@ pub fn media_type_from_message(msg: &wa::Message) -> Option<&'static str> { // WA Web's mediaTypeFromProtobuf treats a top-level lottieStickerMessage as a // terminal "sticker" and does NOT recurse into it (unlike typeAttributeFromProtobuf, // which unwraps it via getUnwrappedProtobufMessage). Check before the shared unwrap. - if msg.lottie_sticker_message.is_some() { + if msg.lottie_sticker_message.is_set() { return Some("sticker"); } let msg = unwrap_message(msg); - if msg.image_message.is_some() { + if msg.image_message.is_set() { return Some("image"); } - if let Some(ref vid) = msg.video_message { + if let Some(vid) = msg.video_message.as_option() { return if vid.gif_playback == Some(true) { Some("gif") } else { Some("video") }; } - if msg.ptv_message.is_some() { + if msg.ptv_message.is_set() { return Some("ptv"); } - if let Some(ref audio) = msg.audio_message { + if let Some(audio) = msg.audio_message.as_option() { return if audio.ptt == Some(true) { Some("ptt") } else { Some("audio") }; } - if msg.document_message.is_some() { + if msg.document_message.is_set() { return Some("document"); } - if msg.sticker_message.is_some() { + if msg.sticker_message.is_set() { return Some("sticker"); } - if msg.sticker_pack_message.is_some() { + if msg.sticker_pack_message.is_set() { return Some("sticker_pack"); } - if let Some(ref loc) = msg.location_message { + if let Some(loc) = msg.location_message.as_option() { return if loc.is_live == Some(true) { Some("livelocation") } else { Some("location") }; } - if msg.live_location_message.is_some() { + if msg.live_location_message.is_set() { return Some("livelocation"); } - if msg.contact_message.is_some() { + if msg.contact_message.is_set() { return Some("vcard"); } - if msg.contacts_array_message.is_some() { + if msg.contacts_array_message.is_set() { return Some("contact_array"); } - if let Some(ref ext) = msg.extended_text_message + if let Some(ext) = msg.extended_text_message.as_option() && ext .matched_text .as_ref() @@ -224,32 +223,32 @@ pub fn media_type_from_message(msg: &wa::Message) -> Option<&'static str> { { return Some("url"); } - if msg.group_invite_message.is_some() { + if msg.group_invite_message.is_set() { return Some("url"); } // Interactive / business message families. WA Web's mediaTypeFromProtobuf maps // each to a concrete mediatype; without it the server drops the type="media" // stanza. buttonsMessage is intentionally absent: WA Web maps it to // EncMediaType.Button, which its string mapper drops (no attribute). - if msg.list_message.is_some() { + if msg.list_message.is_set() { return Some("list"); } - if msg.list_response_message.is_some() { + if msg.list_response_message.is_set() { return Some("list_response"); } - if msg.buttons_response_message.is_some() { + if msg.buttons_response_message.is_set() { return Some("buttons_response"); } - if msg.order_message.is_some() { + if msg.order_message.is_set() { return Some("order"); } - if msg.product_message.is_some() { + if msg.product_message.is_set() { return Some("product"); } - if msg.interactive_response_message.is_some() { + if msg.interactive_response_message.is_set() { return Some("native_flow_response"); } - if msg.message_history_bundle.is_some() { + if msg.message_history_bundle.is_set() { return Some("group_history"); } None @@ -279,38 +278,40 @@ pub fn should_hide_decrypt_fail(msg: &wa::Message) -> bool { use wa::message::protocol_message::Type as ProtocolType; use wa::message::secret_encrypted_message::SecretEncType; - msg.reaction_message.is_some() - || msg.enc_reaction_message.is_some() - || msg.pin_in_chat_message.is_some() - || msg.edited_message.is_some() - || msg.keep_in_chat_message.is_some() - || msg.enc_event_response_message.is_some() + msg.reaction_message.is_set() + || msg.enc_reaction_message.is_set() + || msg.pin_in_chat_message.is_set() + || msg.edited_message.is_set() + || msg.keep_in_chat_message.is_set() + || msg.enc_event_response_message.is_set() || msg .poll_update_message - .as_ref() - .is_some_and(|p| p.vote.is_some()) - || msg.message_history_notice.is_some() - || msg.conditional_reveal_message.is_some() - || msg.secret_encrypted_message.as_ref().is_some_and(|s| { + .as_option() + .is_some_and(|p| p.vote.is_set()) + || msg.message_history_notice.is_set() + || msg.conditional_reveal_message.is_set() + || msg.secret_encrypted_message.as_option().is_some_and(|s| { matches!( - SecretEncType::try_from(s.secret_enc_type.unwrap_or(0)), - Ok(SecretEncType::EventEdit - | SecretEncType::PollEdit - | SecretEncType::PollAddOption) + s.secret_enc_type, + Some( + SecretEncType::EventEdit + | SecretEncType::PollEdit + | SecretEncType::PollAddOption + ) ) }) || msg .bot_invoke_message - .as_ref() - .and_then(|b| b.message.as_ref()) - .and_then(|m| m.protocol_message.as_ref()) - .is_some_and(|p| p.r#type == Some(ProtocolType::RequestWelcomeMessage as i32)) - || msg.protocol_message.as_ref().is_some_and(|p| { + .as_option() + .and_then(|b| b.message.as_option()) + .and_then(|m| m.protocol_message.as_option()) + .is_some_and(|p| p.r#type == Some(ProtocolType::RequestWelcomeMessage)) + || msg.protocol_message.as_option().is_some_and(|p| { matches!( p.r#type, - Some(t) if t == ProtocolType::EphemeralSyncResponse as i32 - || t == ProtocolType::RequestWelcomeMessage as i32 - || t == ProtocolType::GroupMemberLabelChange as i32 - ) || p.edited_message.is_some() + Some(t) if t == ProtocolType::EphemeralSyncResponse + || t == ProtocolType::RequestWelcomeMessage + || t == ProtocolType::GroupMemberLabelChange + ) || p.edited_message.is_set() }) } diff --git a/wacore/src/send/dm.rs b/wacore/src/send/dm.rs index 1c6990d2f..507e1f343 100644 --- a/wacore/src/send/dm.rs +++ b/wacore/src/send/dm.rs @@ -53,7 +53,7 @@ pub async fn prepare_dm_stanza( resolver: &dyn SendContextResolver, own_jid: &Jid, own_lid: Option<&Jid>, - account: Option<&wa::AdvSignedDeviceIdentity>, + account: Option<&wa::ADVSignedDeviceIdentity>, to_jid: Jid, message: &wa::Message, request_id: String, @@ -68,7 +68,7 @@ pub async fn prepare_dm_stanza( // existing mci, diverging from the bytes the token is computed over, so it re-encodes. let shared_content = message .message_context_info - .is_none() + .is_unset() .then(|| waproto::codec::message_to_vec(message)); // sender is the author's own jid, remote is the chat jid (WAWebReportingTokenUtils: @@ -289,7 +289,7 @@ pub async fn prepare_dm_retry_stanza( message: &wa::Message, message_id: String, retry_count: u8, - account: Option<&wa::AdvSignedDeviceIdentity>, + account: Option<&wa::ADVSignedDeviceIdentity>, edit: Option, ) -> Result where diff --git a/wacore/src/send/encrypt.rs b/wacore/src/send/encrypt.rs index 1776cd743..31deb9d83 100644 --- a/wacore/src/send/encrypt.rs +++ b/wacore/src/send/encrypt.rs @@ -164,7 +164,7 @@ pub struct EncryptForDevicesRaw { /// - `!includes_prekey` -> `Ok(None)` pub fn needs_device_identity( includes_prekey: bool, - account: Option<&wa::AdvSignedDeviceIdentity>, + account: Option<&wa::ADVSignedDeviceIdentity>, ) -> Result>> { if !includes_prekey { return Ok(None); diff --git a/wacore/src/send/group.rs b/wacore/src/send/group.rs index be9f39a3b..16d37520e 100644 --- a/wacore/src/send/group.rs +++ b/wacore/src/send/group.rs @@ -16,7 +16,7 @@ pub async fn prepare_group_retry_stanza( message: &wa::Message, message_id: String, retry_count: u8, - account: Option<&wa::AdvSignedDeviceIdentity>, + account: Option<&wa::ADVSignedDeviceIdentity>, addressing_mode: crate::types::message::AddressingMode, edit: Option, ) -> Result @@ -120,7 +120,7 @@ pub async fn prepare_group_stanza( group_info: &GroupInfo, own_jid: &Jid, own_lid: &Jid, - account: Option<&wa::AdvSignedDeviceIdentity>, + account: Option<&wa::ADVSignedDeviceIdentity>, to_jid: Jid, message: &wa::Message, request_id: String, @@ -146,7 +146,7 @@ pub async fn prepare_group_stanza( // existing mci, diverging from the bytes the token is computed over, so it re-encodes. let shared_content = message .message_context_info - .is_none() + .is_unset() .then(|| waproto::codec::message_to_vec(message)); // Generate reporting token if the message type supports it. @@ -404,10 +404,12 @@ pub async fn prepare_group_stanza( if let Some(plan) = session_plan { let skdm_wrapper_msg = wa::Message { - sender_key_distribution_message: Some(wa::message::SenderKeyDistributionMessage { - group_id: Some(to_jid.to_string()), - axolotl_sender_key_distribution_message: Some(axolotl_skdm_bytes), - }), + sender_key_distribution_message: buffa::MessageField::some( + wa::message::SenderKeyDistributionMessage { + group_id: Some(to_jid.to_string()), + axolotl_sender_key_distribution_message: Some(axolotl_skdm_bytes), + }, + ), ..Default::default() }; let skdm_plaintext_to_encrypt = MessageUtils::encode_and_pad(&skdm_wrapper_msg); @@ -639,14 +641,14 @@ pub async fn create_sender_key_distribution_message_for_group( /// `ts_secs` is unix seconds, matching WA Web's `unixTime()`. pub fn build_member_label_message(label: String, ts_secs: i64) -> wa::Message { wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage { - r#type: Some(wa::message::protocol_message::Type::GroupMemberLabelChange as i32), - member_label: Some(wa::MemberLabel { + protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage { + r#type: Some(wa::message::protocol_message::Type::GroupMemberLabelChange), + member_label: buffa::MessageField::some(wa::MemberLabel { label: Some(label), label_timestamp: Some(ts_secs), }), ..Default::default() - })), + }), ..Default::default() } } diff --git a/wacore/src/send/peer.rs b/wacore/src/send/peer.rs index 14cc7ae7c..720ab856c 100644 --- a/wacore/src/send/peer.rs +++ b/wacore/src/send/peer.rs @@ -10,7 +10,7 @@ pub async fn prepare_peer_stanza( signal_address: &ProtocolAddress, message: &wa::Message, request_id: String, - account: Option<&wa::AdvSignedDeviceIdentity>, + account: Option<&wa::ADVSignedDeviceIdentity>, ) -> Result where S: crate::libsignal::protocol::SessionStore, @@ -39,7 +39,7 @@ pub async fn prepare_peer_stanza_with_options( signal_address: &ProtocolAddress, message: &wa::Message, request_id: String, - account: Option<&wa::AdvSignedDeviceIdentity>, + account: Option<&wa::ADVSignedDeviceIdentity>, options: PeerMessageOptions, ) -> Result where diff --git a/wacore/src/send/status.rs b/wacore/src/send/status.rs index 64b2d0a0a..e74330ae7 100644 --- a/wacore/src/send/status.rs +++ b/wacore/src/send/status.rs @@ -89,9 +89,9 @@ pub fn status_carries_privacy_meta(message: &wa::Message) -> bool { let msg = unwrap_message(message); let is_revoke = msg .protocol_message - .as_ref() - .is_some_and(|pm| pm.r#type == Some(wa::message::protocol_message::Type::Revoke as i32)); - let is_reaction = msg.reaction_message.is_some() || msg.enc_reaction_message.is_some(); + .as_option() + .is_some_and(|pm| pm.r#type == Some(wa::message::protocol_message::Type::Revoke)); + let is_reaction = msg.reaction_message.is_set() || msg.enc_reaction_message.is_set(); !is_revoke && !is_reaction } diff --git a/wacore/src/send/tests.rs b/wacore/src/send/tests.rs index 51267e54f..36c57436a 100644 --- a/wacore/src/send/tests.rs +++ b/wacore/src/send/tests.rs @@ -95,26 +95,26 @@ mod peer_message_options { use super::*; use crate::types::message::{PrivacySensitiveType, PushPriority}; - fn pdo_message_raw(request_type: i32) -> wa::Message { + fn pdo_message_raw( + request_type: Option, + ) -> wa::Message { wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage { - r#type: Some( - wa::message::protocol_message::Type::PeerDataOperationRequestMessage as i32, - ), - peer_data_operation_request_message: Some( + protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage { + r#type: Some(wa::message::protocol_message::Type::PeerDataOperationRequestMessage), + peer_data_operation_request_message: buffa::MessageField::some( wa::message::PeerDataOperationRequestMessage { - peer_data_operation_request_type: Some(request_type), + peer_data_operation_request_type: request_type, ..Default::default() }, ), ..Default::default() - })), + }), ..Default::default() } } fn pdo_message(request_type: wa::message::PeerDataOperationRequestType) -> wa::Message { - pdo_message_raw(request_type as i32) + pdo_message_raw(Some(request_type)) } #[test] @@ -177,17 +177,19 @@ mod peer_message_options { #[test] fn non_pdo_and_unknown_pdo_keep_peer_defaults() { let app_state_key_request = wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage { - r#type: Some(wa::message::protocol_message::Type::AppStateSyncKeyRequest as i32), - app_state_sync_key_request: Some(wa::message::AppStateSyncKeyRequest { - key_ids: Vec::new(), - }), + protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage { + r#type: Some(wa::message::protocol_message::Type::AppStateSyncKeyRequest), + app_state_sync_key_request: buffa::MessageField::some( + wa::message::AppStateSyncKeyRequest { + key_ids: Vec::new(), + }, + ), ..Default::default() - })), + }), ..Default::default() }; - for msg in [app_state_key_request, pdo_message_raw(99)] { + for msg in [app_state_key_request, pdo_message_raw(None)] { let options = peer_message_options_from_message(&msg); assert_eq!(options.push_priority(), PushPriority::High); assert_eq!(options.privacy_sensitive(), None); @@ -201,10 +203,10 @@ mod status_carries_privacy_meta { #[test] fn true_for_text_post() { let msg = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { text: Some("hi".into()), ..Default::default() - })), + }), ..Default::default() }; assert!(status_carries_privacy_meta(&msg)); @@ -213,7 +215,7 @@ mod status_carries_privacy_meta { #[test] fn true_for_image_post() { let msg = wa::Message { - image_message: Some(Box::new(wa::message::ImageMessage::default())), + image_message: buffa::MessageField::some(wa::message::ImageMessage::default()), ..Default::default() }; assert!(status_carries_privacy_meta(&msg)); @@ -222,10 +224,10 @@ mod status_carries_privacy_meta { #[test] fn false_for_reaction() { let msg = wa::Message { - reaction_message: Some(Box::new(wa::message::ReactionMessage { + reaction_message: buffa::MessageField::some(wa::message::ReactionMessage { text: Some("💚".into()), ..Default::default() - })), + }), ..Default::default() }; assert!( @@ -237,7 +239,7 @@ mod status_carries_privacy_meta { #[test] fn false_for_enc_reaction() { let msg = wa::Message { - enc_reaction_message: Some(Box::default()), + enc_reaction_message: buffa::MessageField::some(Default::default()), ..Default::default() }; assert!(!status_carries_privacy_meta(&msg)); @@ -246,10 +248,10 @@ mod status_carries_privacy_meta { #[test] fn false_for_revoke() { let msg = wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage { - r#type: Some(wa::message::protocol_message::Type::Revoke as i32), + protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage { + r#type: Some(wa::message::protocol_message::Type::Revoke), ..Default::default() - })), + }), ..Default::default() }; assert!(!status_carries_privacy_meta(&msg)); @@ -260,10 +262,10 @@ mod status_carries_privacy_meta { // Other ProtocolMessage types (e.g., EphemeralSettings) aren't // reactions and aren't revokes — treat as posts for now. let msg = wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage { - r#type: Some(wa::message::protocol_message::Type::EphemeralSetting as i32), + protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage { + r#type: Some(wa::message::protocol_message::Type::EphemeralSetting), ..Default::default() - })), + }), ..Default::default() }; assert!(status_carries_privacy_meta(&msg)); @@ -272,13 +274,13 @@ mod status_carries_privacy_meta { #[test] fn false_for_reaction_inside_ephemeral_wrapper() { let inner = wa::Message { - reaction_message: Some(Box::default()), + reaction_message: buffa::MessageField::some(Default::default()), ..Default::default() }; let msg = wa::Message { - ephemeral_message: Some(Box::new(wa::message::FutureProofMessage { - message: Some(Box::new(inner)), - })), + ephemeral_message: buffa::MessageField::some(wa::message::FutureProofMessage { + message: buffa::MessageField::some(inner), + }), ..Default::default() }; assert!(!status_carries_privacy_meta(&msg)); @@ -287,18 +289,18 @@ mod status_carries_privacy_meta { #[test] fn false_for_revoke_inside_device_sent_wrapper() { let inner = wa::Message { - protocol_message: Some(Box::new(wa::message::ProtocolMessage { - r#type: Some(wa::message::protocol_message::Type::Revoke as i32), + protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage { + r#type: Some(wa::message::protocol_message::Type::Revoke), ..Default::default() - })), + }), ..Default::default() }; let msg = wa::Message { - device_sent_message: Some(Box::new(wa::message::DeviceSentMessage { + device_sent_message: buffa::MessageField::some(wa::message::DeviceSentMessage { destination_jid: Some(String::new()), - message: Some(Box::new(inner)), + message: buffa::MessageField::some(inner), ..Default::default() - })), + }), ..Default::default() }; assert!(!status_carries_privacy_meta(&msg)); @@ -308,16 +310,19 @@ mod status_carries_privacy_meta { #[test] fn build_member_label_message_sets_fields() { let msg = build_member_label_message("VIP".to_string(), 1_766_847_151); - let pm = msg.protocol_message.as_ref().expect("protocol_message set"); + let pm = msg + .protocol_message + .as_option() + .expect("protocol_message set"); assert_eq!( pm.r#type, - Some(wa::message::protocol_message::Type::GroupMemberLabelChange as i32) + Some(wa::message::protocol_message::Type::GroupMemberLabelChange) ); - let ml = pm.member_label.as_ref().expect("member_label set"); + let ml = pm.member_label.as_option().expect("member_label set"); assert_eq!(ml.label.as_deref(), Some("VIP")); assert_eq!(ml.label_timestamp, Some(1_766_847_151)); assert!( - pm.key.is_none(), + pm.key.is_unset(), "MessageKey must NOT be set (WA Web parity)" ); } @@ -327,10 +332,10 @@ fn build_member_label_message_clear_uses_empty_string() { let msg = build_member_label_message(String::new(), 1); let ml = msg .protocol_message - .as_ref() + .as_option() .unwrap() .member_label - .as_ref() + .as_option() .unwrap(); assert_eq!(ml.label.as_deref(), Some("")); } @@ -340,10 +345,10 @@ fn build_member_label_message_preserves_unicode() { let msg = build_member_label_message("🚀 BOT".to_string(), 2); let ml = msg .protocol_message - .as_ref() + .as_option() .unwrap() .member_label - .as_ref() + .as_option() .unwrap(); assert_eq!(ml.label.as_deref(), Some("🚀 BOT")); } @@ -1547,7 +1552,7 @@ mod group_retry { async fn dm_retry_pkmsg_with_account_has_device_identity() { let (mut ss, mut is, jid) = setup_session().await; let to: Jid = "559922223333@s.whatsapp.net".parse().unwrap(); - let acc = wa::AdvSignedDeviceIdentity { + let acc = wa::ADVSignedDeviceIdentity { details: Some(b"t".to_vec()), ..Default::default() }; @@ -1581,7 +1586,7 @@ mod group_retry { let (mut ss, mut is, jid) = setup_session().await; let group: Jid = "120363098765432100@g.us".parse().unwrap(); let p: Jid = jid.to_string().parse().unwrap(); - let acc = wa::AdvSignedDeviceIdentity { + let acc = wa::ADVSignedDeviceIdentity { details: Some(b"t".to_vec()), ..Default::default() }; @@ -1634,7 +1639,7 @@ mod group_retry { &wa::Message::default(), "m2".into(), 3, - Some(&wa::AdvSignedDeviceIdentity::default()), + Some(&wa::ADVSignedDeviceIdentity::default()), AddressingMode::Lid, None, ) @@ -1726,10 +1731,10 @@ mod group_retry { // layer skips session promotion. Mirrors whatsmeow's // `preparePeerMessageNode`. - fn pkmsg_account_proto() -> wa::AdvSignedDeviceIdentity { + fn pkmsg_account_proto() -> wa::ADVSignedDeviceIdentity { // Opaque placeholder bytes — the assertions only check that // the element carries non-empty content. - wa::AdvSignedDeviceIdentity { + wa::ADVSignedDeviceIdentity { details: Some(vec![0u8; 32]), account_signature_key: Some(vec![0u8; 32]), account_signature: Some(vec![0u8; 64]), @@ -1738,13 +1743,13 @@ mod group_retry { } async fn build_peer_stanza( - account: Option<&wa::AdvSignedDeviceIdentity>, + account: Option<&wa::ADVSignedDeviceIdentity>, ) -> wacore_binary::Node { build_peer_stanza_with_options(account, PeerMessageOptions::default()).await } async fn build_peer_stanza_with_options( - account: Option<&wa::AdvSignedDeviceIdentity>, + account: Option<&wa::ADVSignedDeviceIdentity>, options: PeerMessageOptions, ) -> wacore_binary::Node { let (mut ss, mut is, jid) = setup_session().await; @@ -2119,7 +2124,7 @@ mod decrypt_fail { #[test] fn reaction() { let msg = wa::Message { - reaction_message: Some(Box::default()), + reaction_message: buffa::MessageField::some(Default::default()), ..Default::default() }; assert!(should_hide_decrypt_fail(&msg)); @@ -2128,7 +2133,7 @@ mod decrypt_fail { #[test] fn pin() { let msg = wa::Message { - pin_in_chat_message: Some(Box::default()), + pin_in_chat_message: buffa::MessageField::some(Default::default()), ..Default::default() }; assert!(should_hide_decrypt_fail(&msg)); @@ -2137,10 +2142,10 @@ mod decrypt_fail { #[test] fn poll_vote() { let msg = wa::Message { - poll_update_message: Some(Box::new(wa::message::PollUpdateMessage { - vote: Some(Default::default()), + poll_update_message: buffa::MessageField::some(wa::message::PollUpdateMessage { + vote: buffa::MessageField::some(Default::default()), ..Default::default() - })), + }), ..Default::default() }; assert!(should_hide_decrypt_fail(&msg)); @@ -2149,7 +2154,7 @@ mod decrypt_fail { #[test] fn poll_update_without_vote() { let msg = wa::Message { - poll_update_message: Some(Box::default()), + poll_update_message: buffa::MessageField::some(Default::default()), ..Default::default() }; assert!(!should_hide_decrypt_fail(&msg)); @@ -2158,12 +2163,12 @@ mod decrypt_fail { #[test] fn reaction_inside_ephemeral_wrapper() { let msg = wa::Message { - ephemeral_message: Some(Box::new(wa::message::FutureProofMessage { - message: Some(Box::new(wa::Message { - reaction_message: Some(Box::default()), + ephemeral_message: buffa::MessageField::some(wa::message::FutureProofMessage { + message: buffa::MessageField::some(wa::Message { + reaction_message: buffa::MessageField::some(Default::default()), ..Default::default() - })), - })), + }), + }), ..Default::default() }; assert!(should_hide_decrypt_fail(&msg)); @@ -2172,7 +2177,7 @@ mod decrypt_fail { #[test] fn conditional_reveal() { let msg = wa::Message { - conditional_reveal_message: Some(Box::default()), + conditional_reveal_message: buffa::MessageField::some(Default::default()), ..Default::default() }; assert!(should_hide_decrypt_fail(&msg)); @@ -2182,10 +2187,12 @@ mod decrypt_fail { fn poll_add_option_edit() { use wa::message::secret_encrypted_message::SecretEncType; let msg = wa::Message { - secret_encrypted_message: Some(Box::new(wa::message::SecretEncryptedMessage { - secret_enc_type: Some(SecretEncType::PollAddOption as i32), - ..Default::default() - })), + secret_encrypted_message: buffa::MessageField::some( + wa::message::SecretEncryptedMessage { + secret_enc_type: Some(SecretEncType::PollAddOption), + ..Default::default() + }, + ), ..Default::default() }; assert!(should_hide_decrypt_fail(&msg)); @@ -2231,7 +2238,7 @@ mod decrypt_fail_for_send { fn revoke_does_not_block_content_based_hide() { // A reaction still hides on its own merits even under a revoke edit. let msg = wa::Message { - reaction_message: Some(Box::default()), + reaction_message: buffa::MessageField::some(Default::default()), ..Default::default() }; assert!(should_hide_decrypt_fail_for_send( @@ -2247,10 +2254,12 @@ mod stanza_type { fn secret(enc: SecretEncType) -> wa::Message { wa::Message { - secret_encrypted_message: Some(Box::new(wa::message::SecretEncryptedMessage { - secret_enc_type: Some(enc as i32), - ..Default::default() - })), + secret_encrypted_message: buffa::MessageField::some( + wa::message::SecretEncryptedMessage { + secret_enc_type: Some(enc), + ..Default::default() + }, + ), ..Default::default() } } @@ -2274,7 +2283,7 @@ mod stanza_type { #[test] fn album_is_text() { let msg = wa::Message { - album_message: Some(Box::default()), + album_message: buffa::MessageField::some(Default::default()), ..Default::default() }; assert_eq!(stanza_type_from_message(&msg), stanza::MSG_TYPE_TEXT); @@ -2283,10 +2292,10 @@ mod stanza_type { // Helpers for wrapper tests. WA Web's typeAttributeFromProtobuf unwraps // FutureProofMessage wrappers (via getUnwrappedProtobufMessage) and then // classifies the inner message. - fn fpm(inner: wa::Message) -> Box { - Box::new(wa::message::FutureProofMessage { - message: Some(Box::new(inner)), - }) + fn fpm(inner: wa::Message) -> wa::message::FutureProofMessage { + wa::message::FutureProofMessage { + message: buffa::MessageField::some(inner), + } } fn text_inner() -> wa::Message { wa::Message { @@ -2296,7 +2305,7 @@ mod stanza_type { } fn image_inner() -> wa::Message { wa::Message { - image_message: Some(Box::default()), + image_message: buffa::MessageField::some(Default::default()), ..Default::default() } } @@ -2304,7 +2313,7 @@ mod stanza_type { #[test] fn group_status_v2_classifies_by_inner() { let txt = wa::Message { - group_status_message_v2: Some(fpm(text_inner())), + group_status_message_v2: buffa::MessageField::some(fpm(text_inner())), ..Default::default() }; assert_eq!(stanza_type_from_message(&txt), stanza::MSG_TYPE_TEXT); @@ -2313,7 +2322,7 @@ mod stanza_type { // mediatype and silently dropped the stanza. WA Web unwraps it and // sends type="media" mediatype="image". let img = wa::Message { - group_status_message_v2: Some(fpm(image_inner())), + group_status_message_v2: buffa::MessageField::some(fpm(image_inner())), ..Default::default() }; assert_eq!(stanza_type_from_message(&img), stanza::MSG_TYPE_MEDIA); @@ -2326,7 +2335,7 @@ mod stanza_type { // (ephemeral/groupMentioned/botInvoke/deviceSent), so it falls through // to the media default in both WA Web and here. let m = wa::Message { - group_status_message_v2: Some(Box::default()), + group_status_message_v2: buffa::MessageField::some(Default::default()), ..Default::default() }; assert_eq!(stanza_type_from_message(&m), stanza::MSG_TYPE_MEDIA); @@ -2337,23 +2346,23 @@ mod stanza_type { // Payment family classifies as text; the media default would be dropped. let cases = [ wa::Message { - request_payment_message: Some(Box::default()), + request_payment_message: buffa::MessageField::some(Default::default()), ..Default::default() }, wa::Message { - send_payment_message: Some(Box::default()), + send_payment_message: buffa::MessageField::some(Default::default()), ..Default::default() }, wa::Message { - decline_payment_request_message: Some(Box::default()), + decline_payment_request_message: buffa::MessageField::some(Default::default()), ..Default::default() }, wa::Message { - cancel_payment_request_message: Some(Box::default()), + cancel_payment_request_message: buffa::MessageField::some(Default::default()), ..Default::default() }, wa::Message { - payment_invite_message: Some(Box::default()), + payment_invite_message: buffa::MessageField::some(Default::default()), ..Default::default() }, ]; @@ -2366,13 +2375,13 @@ mod stanza_type { #[test] fn backfilled_wrappers_classify_by_inner() { let spoiler = wa::Message { - spoiler_message: Some(fpm(text_inner())), + spoiler_message: buffa::MessageField::some(fpm(text_inner())), ..Default::default() }; assert_eq!(stanza_type_from_message(&spoiler), stanza::MSG_TYPE_TEXT); let status_mention = wa::Message { - status_mention_message: Some(fpm(image_inner())), + status_mention_message: buffa::MessageField::some(fpm(image_inner())), ..Default::default() }; assert_eq!( @@ -2382,13 +2391,13 @@ mod stanza_type { assert_eq!(media_type_from_message(&status_mention), Some("image")); let question = wa::Message { - question_message: Some(fpm(text_inner())), + question_message: buffa::MessageField::some(fpm(text_inner())), ..Default::default() }; assert_eq!(stanza_type_from_message(&question), stanza::MSG_TYPE_TEXT); let group_status_v1 = wa::Message { - group_status_message: Some(fpm(text_inner())), + group_status_message: buffa::MessageField::some(fpm(text_inner())), ..Default::default() }; assert_eq!( @@ -2401,11 +2410,11 @@ mod stanza_type { fn nested_wrappers_reach_innermost() { // ephemeral { viewOnceV2 { image } } -> media + mediatype. let inner = wa::Message { - view_once_message_v2: Some(fpm(image_inner())), + view_once_message_v2: buffa::MessageField::some(fpm(image_inner())), ..Default::default() }; let m = wa::Message { - ephemeral_message: Some(fpm(inner)), + ephemeral_message: buffa::MessageField::some(fpm(inner)), ..Default::default() }; assert_eq!(stanza_type_from_message(&m), stanza::MSG_TYPE_MEDIA); @@ -2415,19 +2424,19 @@ mod stanza_type { #[test] fn preserved_classifier_branches() { let r = wa::Message { - reaction_message: Some(Box::default()), + reaction_message: buffa::MessageField::some(Default::default()), ..Default::default() }; assert_eq!(stanza_type_from_message(&r), stanza::MSG_TYPE_REACTION); let ev = wa::Message { - event_message: Some(Box::default()), + event_message: buffa::MessageField::some(Default::default()), ..Default::default() }; assert_eq!(stanza_type_from_message(&ev), stanza::MSG_TYPE_EVENT); let poll = wa::Message { - poll_creation_message_v3: Some(Box::default()), + poll_creation_message_v3: buffa::MessageField::some(Default::default()), ..Default::default() }; assert_eq!(stanza_type_from_message(&poll), stanza::MSG_TYPE_POLL); @@ -2442,16 +2451,16 @@ mod stanza_type { ); let proto = wa::Message { - protocol_message: Some(Box::default()), + protocol_message: buffa::MessageField::some(Default::default()), ..Default::default() }; assert_eq!(stanza_type_from_message(&proto), stanza::MSG_TYPE_TEXT); let url = wa::Message { - extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage { matched_text: Some("https://example.com".to_string()), ..Default::default() - })), + }), ..Default::default() }; assert_eq!(stanza_type_from_message(&url), stanza::MSG_TYPE_MEDIA); @@ -2462,14 +2471,14 @@ mod stanza_type { // WA Web's mediaTypeFromProtobuf maps these to concrete mediatypes; // omitting the attribute makes the server drop the type="media" stanza. let list = wa::Message { - list_message: Some(Box::default()), + list_message: buffa::MessageField::some(Default::default()), ..Default::default() }; assert_eq!(stanza_type_from_message(&list), stanza::MSG_TYPE_MEDIA); assert_eq!(media_type_from_message(&list), Some("list")); let list_response = wa::Message { - list_response_message: Some(Box::default()), + list_response_message: buffa::MessageField::some(Default::default()), ..Default::default() }; assert_eq!( @@ -2478,7 +2487,7 @@ mod stanza_type { ); let buttons_response = wa::Message { - buttons_response_message: Some(Box::default()), + buttons_response_message: buffa::MessageField::some(Default::default()), ..Default::default() }; assert_eq!( @@ -2487,19 +2496,19 @@ mod stanza_type { ); let order = wa::Message { - order_message: Some(Box::default()), + order_message: buffa::MessageField::some(Default::default()), ..Default::default() }; assert_eq!(media_type_from_message(&order), Some("order")); let product = wa::Message { - product_message: Some(Box::default()), + product_message: buffa::MessageField::some(Default::default()), ..Default::default() }; assert_eq!(media_type_from_message(&product), Some("product")); let interactive_response = wa::Message { - interactive_response_message: Some(Box::default()), + interactive_response_message: buffa::MessageField::some(Default::default()), ..Default::default() }; assert_eq!( @@ -2508,7 +2517,7 @@ mod stanza_type { ); let history_bundle = wa::Message { - message_history_bundle: Some(Box::default()), + message_history_bundle: buffa::MessageField::some(Default::default()), ..Default::default() }; assert_eq!( @@ -2523,7 +2532,7 @@ mod stanza_type { // mapper has no Button case (returns null/DROP_ATTR), so the attribute // is omitted. Adding a "buttons" mediatype would diverge from WA Web. let buttons = wa::Message { - buttons_message: Some(Box::default()), + buttons_message: buffa::MessageField::some(Default::default()), ..Default::default() }; assert_eq!(media_type_from_message(&buttons), None); @@ -2532,8 +2541,8 @@ mod stanza_type { #[test] fn ephemeral_wrapped_list_reaches_list_mediatype() { let m = wa::Message { - ephemeral_message: Some(fpm(wa::Message { - list_message: Some(Box::default()), + ephemeral_message: buffa::MessageField::some(fpm(wa::Message { + list_message: buffa::MessageField::some(Default::default()), ..Default::default() })), ..Default::default() @@ -2547,7 +2556,7 @@ mod stanza_type { // as a terminal "sticker" and does NOT recurse into it, unlike the // stanza-type path which unwraps it. let lottie = wa::Message { - lottie_sticker_message: Some(fpm(image_inner())), + lottie_sticker_message: buffa::MessageField::some(fpm(image_inner())), ..Default::default() }; assert_eq!(media_type_from_message(&lottie), Some("sticker")); @@ -3119,10 +3128,10 @@ mod mark_full_distribution_list { // Excluded type (reaction) → no secret, no node. let reaction = wa::Message { - reaction_message: Some(Box::new(wa::message::ReactionMessage { + reaction_message: buffa::MessageField::some(wa::message::ReactionMessage { text: Some("👍".into()), ..Default::default() - })), + }), ..Default::default() }; let (node, has_secret) = prepare( diff --git a/wacore/src/shortcake.rs b/wacore/src/shortcake.rs index 443e4ad21..df13865d0 100644 --- a/wacore/src/shortcake.rs +++ b/wacore/src/shortcake.rs @@ -31,9 +31,9 @@ use crate::libsignal::crypto::aes_256_gcm_encrypt; use crate::libsignal::protocol::{CurveError, KeyPair, PublicKey}; use crate::pair_code::PairCodeUtils; +use buffa::{Enumeration, Message}; use hkdf::Hkdf; use hmac::{Hmac, KeyInit as _, Mac}; -use prost::Message; use rand::RngExt; use sha2::{Digest, Sha256}; use waproto::whatsapp as wa; @@ -86,10 +86,11 @@ pub struct ShortcakeUtils; impl ShortcakeUtils { /// Encode the companion ephemeral identity protobuf. /// `public_key` is the RAW 32-byte X25519 pubkey (no 0x05 prefix). - /// `device_type` is the numeric `DeviceProps.PlatformType` (CHROME = 1). + /// Typed `device_type` keeps the wire value and the key-derivation salt in + /// lockstep by construction (the salt embeds the same enum number). pub fn build_companion_ephemeral_identity( public_key: &[u8; 32], - device_type: i32, + device_type: wa::device_props::PlatformType, ref_str: &str, ) -> Vec { wa::CompanionEphemeralIdentity { @@ -119,7 +120,7 @@ impl ShortcakeUtils { ) -> Vec { wa::ProloguePayload { companion_ephemeral_identity: Some(companion_ephemeral_identity.to_vec()), - commitment: Some(wa::CompanionCommitment { + commitment: buffa::MessageField::some(wa::CompanionCommitment { hash: Some(commitment_hash.to_vec()), }), } @@ -133,7 +134,7 @@ impl ShortcakeUtils { pub fn parse_primary_ephemeral_identity( bytes: &[u8], ) -> Result { - let parsed = wa::PrimaryEphemeralIdentity::decode(bytes) + let parsed = wa::PrimaryEphemeralIdentity::decode_from_slice(bytes) .map_err(|_| ShortcakeError::Decode("primary_ephemeral_identity"))?; let pk = parsed.public_key.unwrap_or_default(); let nc = parsed.nonce.unwrap_or_default(); @@ -176,13 +177,17 @@ impl ShortcakeUtils { } /// Derive the AES-256 pairing-request encryption key from the shared secret - /// (deterministic core, unit-testable). `device_type` is the numeric enum value. + /// (deterministic core, unit-testable). The salt embeds the numeric value of + /// `device_type`, matching what the identity protobuf carried on the wire. pub fn derive_encryption_key_from_shared_secret( shared_secret: &[u8; 32], - device_type: i32, + device_type: wa::device_props::PlatformType, ref_str: &str, ) -> Result<[u8; 32], ShortcakeError> { - let salt = format!("Companion Pairing {device_type} with ref {ref_str}"); + let salt = format!( + "Companion Pairing {} with ref {ref_str}", + device_type.to_i32() + ); let hk = Hkdf::::new(Some(salt.as_bytes()), shared_secret); let mut key = [0u8; 32]; hk.expand(ENC_KEY_INFO, &mut key) @@ -194,7 +199,7 @@ impl ShortcakeUtils { pub fn derive_encryption_key( companion_keypair: &KeyPair, primary_public_key: &[u8; 32], - device_type: i32, + device_type: wa::device_props::PlatformType, ref_str: &str, ) -> Result<[u8; 32], ShortcakeError> { let primary = PublicKey::from_djb_public_key_bytes(primary_public_key) @@ -399,8 +404,12 @@ mod tests { #[test] fn encryption_key_uses_string_as_salt_not_info() { let ikm = [9u8; 32]; - let key = - ShortcakeUtils::derive_encryption_key_from_shared_secret(&ikm, 1, "REF123").unwrap(); + let key = ShortcakeUtils::derive_encryption_key_from_shared_secret( + &ikm, + wa::device_props::PlatformType::CHROME, + "REF123", + ) + .unwrap(); // independent re-derivation with the documented salt/info placement let salt = "Companion Pairing 1 with ref REF123"; let hk = Hkdf::::new(Some(salt.as_bytes()), &ikm); @@ -416,11 +425,21 @@ mod tests { // device_type and ref are bound into the key assert_ne!( key, - ShortcakeUtils::derive_encryption_key_from_shared_secret(&ikm, 2, "REF123").unwrap() + ShortcakeUtils::derive_encryption_key_from_shared_secret( + &ikm, + wa::device_props::PlatformType::FIREFOX, + "REF123" + ) + .unwrap() ); assert_ne!( key, - ShortcakeUtils::derive_encryption_key_from_shared_secret(&ikm, 1, "OTHER").unwrap() + ShortcakeUtils::derive_encryption_key_from_shared_secret( + &ikm, + wa::device_props::PlatformType::CHROME, + "OTHER" + ) + .unwrap() ); } @@ -443,25 +462,32 @@ mod tests { #[test] fn protobufs_roundtrip_with_expected_fields() { - let id = ShortcakeUtils::build_companion_ephemeral_identity(&[0xAA; 32], 1, "theref"); - let decoded = wa::CompanionEphemeralIdentity::decode(id.as_slice()).unwrap(); + let id = ShortcakeUtils::build_companion_ephemeral_identity( + &[0xAA; 32], + wa::device_props::PlatformType::CHROME, + "theref", + ); + let decoded = wa::CompanionEphemeralIdentity::decode_from_slice(id.as_slice()).unwrap(); assert_eq!(decoded.public_key.as_deref(), Some(&[0xAA; 32][..])); - assert_eq!(decoded.device_type, Some(1)); + assert_eq!( + decoded.device_type, + Some(wa::device_props::PlatformType::CHROME) + ); assert_eq!(decoded.r#ref.as_deref(), Some("theref")); let prologue = ShortcakeUtils::build_prologue_payload(&id, &[0xBB; 32]); - let dp = wa::ProloguePayload::decode(prologue.as_slice()).unwrap(); + let dp = wa::ProloguePayload::decode_from_slice(prologue.as_slice()).unwrap(); assert_eq!( dp.companion_ephemeral_identity.as_deref(), Some(id.as_slice()) ); assert_eq!( - dp.commitment.and_then(|c| c.hash).as_deref(), + dp.commitment.into_option().and_then(|c| c.hash).as_deref(), Some(&[0xBB; 32][..]) ); let pr = ShortcakeUtils::build_pairing_request(&[1; 32], &[2; 32], &[3; 32]); - let dpr = wa::PairingRequest::decode(pr.as_slice()).unwrap(); + let dpr = wa::PairingRequest::decode_from_slice(pr.as_slice()).unwrap(); assert_eq!(dpr.companion_public_key.as_deref(), Some(&[1u8; 32][..])); assert_eq!(dpr.companion_identity_key.as_deref(), Some(&[2u8; 32][..])); assert_eq!(dpr.adv_secret.as_deref(), Some(&[3u8; 32][..])); @@ -475,7 +501,7 @@ mod tests { // ciphertext + 16-byte GCM tag assert_eq!(enc.encrypted_payload.len(), b"hello pairing".len() + 16); let wire = ShortcakeUtils::build_encrypted_pairing_request(&enc); - let d = wa::EncryptedPairingRequest::decode(wire.as_slice()).unwrap(); + let d = wa::EncryptedPairingRequest::decode_from_slice(wire.as_slice()).unwrap(); assert_eq!(d.iv.as_deref(), Some(&enc.iv[..])); assert_eq!(d.encrypted_payload, Some(enc.encrypted_payload)); } @@ -487,7 +513,7 @@ mod tests { fn full_handshake_interops_with_a_simulated_primary() { use crate::libsignal::crypto::aes_256_gcm_decrypt; - let device_type = 1; // CHROME + let device_type = wa::device_props::PlatformType::CHROME; let pairing_ref = "REF-XYZ"; let prior_adv_secret = [0x11u8; 32]; // a prior linked session's secret let new_adv_secret = [0x22u8; 32]; // rotated for this link @@ -570,7 +596,7 @@ mod tests { ShortcakeUtils::build_pairing_request(&[0xAA; 32], &[0xBB; 32], &new_adv_secret); let enc = ShortcakeUtils::encrypt_pairing_request(&request, &companion_key).unwrap(); let wire = ShortcakeUtils::build_encrypted_pairing_request(&enc); - let decoded = wa::EncryptedPairingRequest::decode(wire.as_slice()).unwrap(); + let decoded = wa::EncryptedPairingRequest::decode_from_slice(wire.as_slice()).unwrap(); let iv: [u8; 12] = decoded.iv.unwrap().as_slice().try_into().unwrap(); let mut plaintext = Vec::new(); @@ -582,7 +608,7 @@ mod tests { &mut plaintext, ) .unwrap(); - let recovered = wa::PairingRequest::decode(plaintext.as_slice()).unwrap(); + let recovered = wa::PairingRequest::decode_from_slice(plaintext.as_slice()).unwrap(); assert_eq!(recovered.adv_secret.as_deref(), Some(&new_adv_secret[..])); assert_eq!( recovered.companion_public_key.as_deref(), diff --git a/wacore/src/stanza/business.rs b/wacore/src/stanza/business.rs index 259c0e73f..1511b2b31 100644 --- a/wacore/src/stanza/business.rs +++ b/wacore/src/stanza/business.rs @@ -3,7 +3,7 @@ //! Reference: WhatsApp Web `WAWebHandleBusinessNotification` use anyhow::{Result, anyhow}; -use prost::Message as _; +use buffa::Message as _; use serde::Serialize; use wacore_binary::Jid; use wacore_binary::NodeRef; @@ -77,10 +77,13 @@ impl VerifiedName { // lives only inside the certificate protobuf (content bytes). Decode it // to fill the missing fields, matching WAWebCommonParsersVerifiedName. if let Some(cert_bytes) = certificate.as_deref() - && let Ok(cert) = waproto::whatsapp::VerifiedNameCertificate::decode(cert_bytes) + && let Ok(cert) = + waproto::whatsapp::VerifiedNameCertificate::decode_from_slice(cert_bytes) && let Some(details_bytes) = cert.details.as_deref() && let Ok(details) = - waproto::whatsapp::verified_name_certificate::Details::decode(details_bytes) + waproto::whatsapp::verified_name_certificate::Details::decode_from_slice( + details_bytes, + ) { name = name.or(details.verified_name); serial = serial.or_else(|| details.serial.map(|s| s.to_string())); diff --git a/wacore/src/sticker_pack.rs b/wacore/src/sticker_pack.rs index 96b73e703..0c309ae0c 100644 --- a/wacore/src/sticker_pack.rs +++ b/wacore/src/sticker_pack.rs @@ -182,7 +182,7 @@ pub fn create_sticker_pack_zip( accessibility_label: input.accessibility_label.clone(), is_lottie: Some(false), mimetype: Some("image/webp".to_string()), - premium: None, + ..Default::default() }); } @@ -234,7 +234,7 @@ pub fn build_sticker_pack_message( }; Ok(wa::Message { - sticker_pack_message: Some(Box::new(pack_msg)), + sticker_pack_message: buffa::MessageField::some(pack_msg), ..Default::default() }) } @@ -520,7 +520,7 @@ mod tests { let msg = build_sticker_pack_message(&zip_result, &zip_upload, &thumb_upload, metadata).unwrap(); - let pack = msg.sticker_pack_message.unwrap(); + let pack = msg.sticker_pack_message.as_option().unwrap(); assert_eq!(pack.sticker_pack_id.as_deref(), Some("msg-test")); assert_eq!(pack.name.as_deref(), Some("Test Pack")); diff --git a/wacore/src/store/commands.rs b/wacore/src/store/commands.rs index c731ec412..800c02c55 100644 --- a/wacore/src/store/commands.rs +++ b/wacore/src/store/commands.rs @@ -9,7 +9,7 @@ pub enum DeviceCommand { SetId(Option), SetLid(Option), SetPushName(String), - SetAccount(Option), + SetAccount(Option), SetAppVersion((u32, u32, u32)), SetDeviceProps(DevicePropsOverride), SetClientProfile(ClientProfile), diff --git a/wacore/src/store/device.rs b/wacore/src/store/device.rs index 399ff46c1..065cad826 100644 --- a/wacore/src/store/device.rs +++ b/wacore/src/store/device.rs @@ -1,27 +1,27 @@ use crate::client_profile::ClientProfile; use crate::libsignal::protocol::{IdentityKeyPair, KeyPair}; -use prost::Message; +use buffa::Message; use serde::{Deserialize, Serialize}; use serde_big_array::BigArray; use std::sync::{Arc, LazyLock}; use wacore_binary::Jid; use waproto::whatsapp as wa; -/// Protobuf-bytes serde for `AdvSignedDeviceIdentity` (prost types lack `Deserialize`). +/// Protobuf-bytes serde for `ADVSignedDeviceIdentity` (the generated types lack `Deserialize`). pub mod account_serde { - use prost::Message; + use buffa::Message; use waproto::whatsapp as wa; - pub fn to_bytes(account: &wa::AdvSignedDeviceIdentity) -> Vec { + pub fn to_bytes(account: &wa::ADVSignedDeviceIdentity) -> Vec { account.encode_to_vec() } - pub fn from_bytes(bytes: &[u8]) -> Result { - wa::AdvSignedDeviceIdentity::decode(bytes) + pub fn from_bytes(bytes: &[u8]) -> Result { + wa::ADVSignedDeviceIdentity::decode_from_slice(bytes) } pub fn serialize( - val: &Option>, + val: &Option>, s: S, ) -> Result { match val { @@ -32,7 +32,7 @@ pub mod account_serde { pub fn deserialize<'de, D: serde::Deserializer<'de>>( d: D, - ) -> Result>, D::Error> { + ) -> Result>, D::Error> { let bytes: Option> = serde::Deserialize::deserialize(d)?; match bytes { Some(b) => from_bytes(&b) @@ -88,10 +88,10 @@ fn build_base_client_payload( // audit auto-generated a UUID per build, which the server flagged as a // rotating device fingerprint and silently invalidated the session. wa::ClientPayload { - user_agent: Some(wa::client_payload::UserAgent { - platform: Some(profile.user_agent_platform as i32), - release_channel: Some(wa::client_payload::user_agent::ReleaseChannel::Release as i32), - app_version: Some(app_version), + user_agent: buffa::MessageField::some(wa::client_payload::UserAgent { + platform: Some(profile.user_agent_platform), + release_channel: Some(wa::client_payload::user_agent::ReleaseChannel::RELEASE), + app_version: buffa::MessageField::some(app_version), mcc: Some("000".to_string()), mnc: Some("000".to_string()), os_version: Some(profile.os_version.clone()), @@ -103,16 +103,16 @@ fn build_base_client_payload( phone_id: profile.phone_id.clone(), ..Default::default() }), - web_info: profile - .include_web_info - .then(|| wa::client_payload::WebInfo { - web_sub_platform: Some( - wa::client_payload::web_info::WebSubPlatform::WebBrowser as i32, - ), + web_info: if profile.include_web_info { + buffa::MessageField::some(wa::client_payload::WebInfo { + web_sub_platform: Some(wa::client_payload::web_info::WebSubPlatform::WEB_BROWSER), ..Default::default() - }), - connect_type: Some(wa::client_payload::ConnectType::WifiUnknown as i32), - connect_reason: Some(wa::client_payload::ConnectReason::UserActivated as i32), + }) + } else { + buffa::MessageField::default() + }, + connect_type: Some(wa::client_payload::ConnectType::WIFI_UNKNOWN), + connect_reason: Some(wa::client_payload::ConnectReason::USER_ACTIVATED), ..Default::default() } } @@ -198,15 +198,15 @@ pub fn default_history_sync_config() -> wa::device_props::HistorySyncConfig { pub static DEVICE_PROPS: LazyLock = LazyLock::new(|| wa::DeviceProps { os: Some("rust".to_string()), - version: Some(wa::device_props::AppVersion { + version: buffa::MessageField::some(wa::device_props::AppVersion { primary: Some(0), secondary: Some(1), tertiary: Some(0), ..Default::default() }), - platform_type: Some(wa::device_props::PlatformType::Unknown as i32), + platform_type: Some(wa::device_props::PlatformType::UNKNOWN), require_full_sync: Some(true), - history_sync_config: Some(default_history_sync_config()), + history_sync_config: buffa::MessageField::some(default_history_sync_config()), }); #[derive(Clone, Serialize, Deserialize)] @@ -227,7 +227,7 @@ pub struct Device { // Arc: immutable after pairing, so per-snapshot clones bump a refcount // instead of deep-copying its four Vec fields. #[serde(with = "account_serde", default)] - pub account: Option>, + pub account: Option>, pub push_name: String, pub app_version_primary: u32, pub app_version_secondary: u32, @@ -422,13 +422,13 @@ impl Device { props.os = Some(os); } if let Some(version) = o.version { - props.version = Some(version); + props.version = buffa::MessageField::some(version); } if let Some(platform_type) = o.platform_type { - props.platform_type = Some(platform_type as i32); + props.platform_type = Some(platform_type); } if let Some(history_sync_config) = o.history_sync_config { - props.history_sync_config = Some(history_sync_config); + props.history_sync_config = buffa::MessageField::some(history_sync_config); } } @@ -475,18 +475,12 @@ impl Device { let device_props_bytes = self.device_props.encode_to_vec(); - let version = payload - .user_agent - .as_ref() - .expect("payload should have user_agent") - .app_version - .as_ref() - .expect("user_agent should have app_version"); + let version = &payload.user_agent.app_version; let version_str = format!( "{}.{}.{}", - version.primary(), - version.secondary(), - version.tertiary() + version.primary.unwrap_or(0), + version.secondary.unwrap_or(0), + version.tertiary.unwrap_or(0) ); let build_hash: [u8; 16] = md5::compute(version_str.as_bytes()).into(); @@ -501,7 +495,7 @@ impl Device { device_props: Some(device_props_bytes), }; - payload.device_pairing_data = Some(reg_data); + payload.device_pairing_data = buffa::MessageField::some(reg_data); payload.passive = Some(false); payload.pull = Some(false); @@ -588,7 +582,7 @@ mod tests { #[test] fn test_device_serde_preserves_account() { let mut device = Device::new(); - device.account = Some(Arc::new(wa::AdvSignedDeviceIdentity { + device.account = Some(Arc::new(wa::ADVSignedDeviceIdentity { details: Some(b"test-details".to_vec()), account_signature_key: Some(vec![1; 32]), account_signature: Some(vec![2; 64]), @@ -622,21 +616,28 @@ mod tests { device.set_device_props( DevicePropsOverride::new() .with_os("Android 14") - .with_platform_type(wa::device_props::PlatformType::AndroidPhone), + .with_platform_type(wa::device_props::PlatformType::ANDROID_PHONE), ); let payload = device.get_client_payload(); - let reg = payload.device_pairing_data.expect("device_pairing_data"); + let reg = payload + .device_pairing_data + .into_option() + .expect("device_pairing_data"); let bytes = reg.device_props.expect("device_props bytes"); - let props = wa::DeviceProps::decode(bytes.as_slice()).expect("decode DeviceProps"); + let props = + wa::DeviceProps::decode_from_slice(bytes.as_slice()).expect("decode DeviceProps"); assert_eq!(props.os.as_deref(), Some("Android 14")); assert_eq!( props.platform_type, - Some(wa::device_props::PlatformType::AndroidPhone as i32) + Some(wa::device_props::PlatformType::ANDROID_PHONE) ); // None preserves the default version. - assert_eq!(props.version, Some(Device::default_device_props_version())); + assert_eq!( + props.version.as_option(), + Some(&Device::default_device_props_version()) + ); } /// `HistorySyncConfig` override is delivered whole — users patch by @@ -655,11 +656,16 @@ mod tests { let payload = device.get_client_payload(); let bytes = payload .device_pairing_data + .into_option() .expect("device_pairing_data") .device_props .expect("device_props bytes"); - let props = wa::DeviceProps::decode(bytes.as_slice()).expect("decode DeviceProps"); - let hsc = props.history_sync_config.expect("history_sync_config"); + let props = + wa::DeviceProps::decode_from_slice(bytes.as_slice()).expect("decode DeviceProps"); + let hsc = props + .history_sync_config + .into_option() + .expect("history_sync_config"); assert_eq!(hsc.full_sync_days_limit, Some(365)); assert_eq!(hsc.support_group_history, Some(true)); @@ -676,12 +682,12 @@ mod tests { device.pn = Some("12345@s.whatsapp.net".parse().unwrap()); device.set_device_props( DevicePropsOverride::new() - .with_platform_type(wa::device_props::PlatformType::AndroidPhone), + .with_platform_type(wa::device_props::PlatformType::ANDROID_PHONE), ); let payload = device.get_client_payload(); assert!( - payload.device_pairing_data.is_none(), + payload.device_pairing_data.is_unset(), "login payload must not carry device_pairing_data" ); } @@ -690,16 +696,22 @@ mod tests { fn default_profile_emits_legacy_web_payload() { let device = Device::new(); let payload = device.get_client_payload(); - let ua = payload.user_agent.expect("user_agent"); - assert_eq!(ua.platform(), wa::client_payload::user_agent::Platform::Web); + let ua = payload.user_agent.as_option().expect("user_agent"); + assert_eq!( + ua.platform, + Some(wa::client_payload::user_agent::Platform::WEB) + ); assert_eq!(ua.device.as_deref(), Some("Desktop")); assert_eq!(ua.os_version.as_deref(), Some("0.1.0")); assert_eq!(ua.os_build_number.as_deref(), Some("0.1.0")); assert_eq!(ua.manufacturer.as_deref(), Some("")); - let web_info = payload.web_info.expect("web profile must include web_info"); + let web_info = payload + .web_info + .as_option() + .expect("web profile must include web_info"); assert_eq!( - web_info.web_sub_platform(), - wa::client_payload::web_info::WebSubPlatform::WebBrowser + web_info.web_sub_platform, + Some(wa::client_payload::web_info::WebSubPlatform::WEB_BROWSER) ); } @@ -709,16 +721,16 @@ mod tests { device.set_client_profile(ClientProfile::android("13")); let payload = device.get_client_payload(); - let ua = payload.user_agent.expect("user_agent"); + let ua = payload.user_agent.as_option().expect("user_agent"); assert_eq!( - ua.platform(), - wa::client_payload::user_agent::Platform::Android + ua.platform, + Some(wa::client_payload::user_agent::Platform::ANDROID) ); assert_eq!(ua.device.as_deref(), Some("Smartphone")); assert_eq!(ua.os_version.as_deref(), Some("13")); assert_eq!(ua.os_build_number.as_deref(), Some("13")); assert!( - payload.web_info.is_none(), + payload.web_info.is_unset(), "android profile must omit web_info" ); } @@ -730,14 +742,14 @@ mod tests { device.pn = Some("12345@s.whatsapp.net".parse().unwrap()); let payload = device.get_client_payload(); - let ua = payload.user_agent.expect("user_agent"); + let ua = payload.user_agent.as_option().expect("user_agent"); assert_eq!( - ua.platform(), - wa::client_payload::user_agent::Platform::Android + ua.platform, + Some(wa::client_payload::user_agent::Platform::ANDROID) ); - assert!(payload.web_info.is_none()); + assert!(payload.web_info.is_unset()); assert!( - payload.device_pairing_data.is_none(), + payload.device_pairing_data.is_unset(), "login payload still must not carry device_pairing_data" ); } @@ -747,13 +759,16 @@ mod tests { let mut device = Device::new(); device.set_device_props( DevicePropsOverride::new() - .with_platform_type(wa::device_props::PlatformType::AndroidPhone), + .with_platform_type(wa::device_props::PlatformType::ANDROID_PHONE), ); let payload = device.get_client_payload(); - let ua = payload.user_agent.expect("user_agent"); - assert_eq!(ua.platform(), wa::client_payload::user_agent::Platform::Web); - assert!(payload.web_info.is_some()); + let ua = payload.user_agent.as_option().expect("user_agent"); + assert_eq!( + ua.platform, + Some(wa::client_payload::user_agent::Platform::WEB) + ); + assert!(payload.web_info.is_set()); } #[test] @@ -770,10 +785,10 @@ mod tests { device.set_client_profile(profile); let payload = device.get_client_payload(); - let ua = payload.user_agent.expect("user_agent"); - assert_eq!(ua.platform(), platform); + let ua = payload.user_agent.as_option().expect("user_agent"); + assert_eq!(ua.platform, Some(platform)); assert!( - payload.web_info.is_none(), + payload.web_info.is_unset(), "{platform:?} must omit web_info" ); } @@ -786,8 +801,8 @@ mod tests { let device = Device::new(); let payload_a = device.get_client_payload(); let payload_b = device.get_client_payload(); - let ua_a = payload_a.user_agent.as_ref().expect("user_agent"); - let ua_b = payload_b.user_agent.as_ref().expect("user_agent"); + let ua_a = payload_a.user_agent.as_option().expect("user_agent"); + let ua_b = payload_b.user_agent.as_option().expect("user_agent"); assert!( ua_a.phone_id.is_none(), "default ClientProfile must leave UserAgent.phoneId unset (got {:?})", @@ -814,7 +829,7 @@ mod tests { device.set_client_profile(profile); let payload = device.get_client_payload(); - let ua = payload.user_agent.expect("user_agent"); + let ua = payload.user_agent.as_option().expect("user_agent"); assert_eq!(ua.phone_id.as_deref(), Some("fixed-test-id")); } @@ -823,7 +838,7 @@ mod tests { let mut device = Device::new(); device.pn = Some("12345:0@s.whatsapp.net".parse().unwrap()); let payload = device.get_client_payload(); - let ua = payload.user_agent.expect("user_agent"); + let ua = payload.user_agent.as_option().expect("user_agent"); assert!( ua.phone_id.is_none(), "login payload phoneId must be omitted (WA Web compliance)" diff --git a/wacore/src/types/events.rs b/wacore/src/types/events.rs index 7d982f973..727e424e2 100755 --- a/wacore/src/types/events.rs +++ b/wacore/src/types/events.rs @@ -107,7 +107,7 @@ impl LazyHistorySync { self.progress } - /// `None` for server-pushed syncs (e.g. `INITIAL_BOOTSTRAP`). + /// `None` for server-pushed syncs (e.g. `InitialBootstrap`). pub fn peer_data_request_session_id(&self) -> Option<&str> { self.peer_data_request_session_id.as_deref() } @@ -1337,7 +1337,7 @@ pub struct LabelAssociationUpdate { #[cfg(test)] mod tests { use super::*; - use prost::Message; + use buffa::Message; use waproto::whatsapp as wa; #[test] @@ -1377,7 +1377,7 @@ mod tests { use flate2::{Compression, write::ZlibEncoder}; use std::io::Write; let hs = wa::HistorySync { - sync_type: wa::history_sync::HistorySyncType::InitialBootstrap as i32, + sync_type: wa::history_sync::HistorySyncType::InitialBootstrap, conversations, ..Default::default() }; @@ -1455,7 +1455,7 @@ mod tests { // Consumer can partial-decode from the inflated bytes. let raw = lazy.decompress().expect("inflates"); assert_eq!(raw.len(), lazy.decompressed_size()); - let decoded = wa::HistorySync::decode(&raw[..]).expect("should decode"); + let decoded = wa::HistorySync::decode_from_slice(&raw[..]).expect("should decode"); assert_eq!(decoded.conversations[0].id, "raw@s.whatsapp.net"); // No caching: a second call inflates again and matches. @@ -1512,7 +1512,7 @@ mod tests { let remainder = stream.remainder().expect("remainder decodes"); assert!(remainder.conversations.is_empty()); assert_eq!( - remainder.sync_type(), + remainder.sync_type, wa::history_sync::HistorySyncType::InitialBootstrap ); } @@ -1578,13 +1578,15 @@ mod tests { let conv = wa::Conversation { id: "chat@s.whatsapp.net".to_string(), messages: vec![wa::HistorySyncMsg { - message: Some(Box::new(wa::WebMessageInfo { + message: wa::WebMessageInfo { key: wa::MessageKey { id: Some("msg-0".to_string()), ..Default::default() - }, + } + .into(), ..Default::default() - })), + } + .into(), msg_order_id: Some(0), }], ..Default::default() @@ -1596,7 +1598,7 @@ mod tests { assert_eq!( hs.conversations[0].messages[0] .message - .as_ref() + .as_option() .unwrap() .key .id diff --git a/wacore/src/types/message.rs b/wacore/src/types/message.rs index 58bdb0726..ec9a1bdaf 100644 --- a/wacore/src/types/message.rs +++ b/wacore/src/types/message.rs @@ -198,42 +198,41 @@ impl EditAttribute { let msg = crate::send::unwrap_message(msg); - if msg.pin_in_chat_message.is_some() { + if msg.pin_in_chat_message.is_set() { return Some(Self::PinInChat); } - if msg.edited_message.is_some() { + if msg.edited_message.is_set() { return Some(Self::MessageEdit); } - if let Some(pm) = msg.protocol_message.as_deref() { - if pm.r#type == Some(ProtocolType::Revoke as i32) { - let from_me = pm.key.as_ref().and_then(|k| k.from_me).unwrap_or(false); + if let Some(pm) = msg.protocol_message.as_option() { + if pm.r#type == Some(ProtocolType::REVOKE) { + let from_me = pm.key.as_option().and_then(|k| k.from_me).unwrap_or(false); return Some(if from_me { Self::SenderRevoke } else { Self::AdminRevoke }); } - if pm.r#type == Some(ProtocolType::MessageEdit as i32) || pm.edited_message.is_some() { + if pm.r#type == Some(ProtocolType::MESSAGE_EDIT) || pm.edited_message.is_set() { return Some(Self::MessageEdit); } } - if let Some(sec) = msg.secret_encrypted_message.as_ref() + if let Some(sec) = msg.secret_encrypted_message.as_option() && let Some(enc_type) = sec.secret_enc_type - && (enc_type == SecretEncType::MessageEdit as i32 - || enc_type == SecretEncType::EventEdit as i32) + && (enc_type == SecretEncType::MESSAGE_EDIT || enc_type == SecretEncType::EVENT_EDIT) { return Some(Self::MessageEdit); } // Reaction with empty text == sender-revoke of a previous reaction. - if let Some(react) = msg.reaction_message.as_ref() + if let Some(react) = msg.reaction_message.as_option() && react.text.as_deref() == Some("") { return Some(Self::SenderRevoke); } // KeepInChat UNDO_KEEP_FOR_ALL is a sender-revoke at the wire level. - if let Some(keep) = msg.keep_in_chat_message.as_ref() - && keep.key.as_ref().and_then(|k| k.from_me) == Some(true) - && keep.keep_type == Some(waproto::whatsapp::KeepType::UndoKeepForAll as i32) + if let Some(keep) = msg.keep_in_chat_message.as_option() + && keep.key.as_option().and_then(|k| k.from_me) == Some(true) + && keep.keep_type == Some(waproto::whatsapp::KeepType::UNDO_KEEP_FOR_ALL) { return Some(Self::SenderRevoke); } @@ -363,6 +362,7 @@ impl MessageInfo { #[cfg(test)] mod tests { use super::*; + use buffa::MessageField; #[test] fn is_self_fanout_matches_only_own_dm_with_recipient() { @@ -527,14 +527,14 @@ mod tests { #[test] fn infer_from_message_admin_revoke() { let msg = waproto::whatsapp::Message { - protocol_message: Some(Box::new(waproto::whatsapp::message::ProtocolMessage { - key: Some(waproto::whatsapp::MessageKey { + protocol_message: MessageField::some(waproto::whatsapp::message::ProtocolMessage { + key: MessageField::some(waproto::whatsapp::MessageKey { from_me: Some(false), ..Default::default() }), - r#type: Some(waproto::whatsapp::message::protocol_message::Type::Revoke as i32), + r#type: Some(waproto::whatsapp::message::protocol_message::Type::REVOKE), ..Default::default() - })), + }), ..Default::default() }; assert_eq!( @@ -546,14 +546,14 @@ mod tests { #[test] fn infer_from_message_sender_revoke() { let msg = waproto::whatsapp::Message { - protocol_message: Some(Box::new(waproto::whatsapp::message::ProtocolMessage { - key: Some(waproto::whatsapp::MessageKey { + protocol_message: MessageField::some(waproto::whatsapp::message::ProtocolMessage { + key: MessageField::some(waproto::whatsapp::MessageKey { from_me: Some(true), ..Default::default() }), - r#type: Some(waproto::whatsapp::message::protocol_message::Type::Revoke as i32), + r#type: Some(waproto::whatsapp::message::protocol_message::Type::REVOKE), ..Default::default() - })), + }), ..Default::default() }; assert_eq!( @@ -565,9 +565,9 @@ mod tests { #[test] fn infer_from_message_top_level_edit() { let msg = waproto::whatsapp::Message { - edited_message: Some(Box::new(waproto::whatsapp::message::FutureProofMessage { - message: Some(Box::new(waproto::whatsapp::Message::default())), - })), + edited_message: MessageField::some(waproto::whatsapp::message::FutureProofMessage { + message: MessageField::some(waproto::whatsapp::Message::default()), + }), ..Default::default() }; assert_eq!( @@ -579,10 +579,10 @@ mod tests { #[test] fn infer_from_message_legacy_edit() { let msg = waproto::whatsapp::Message { - protocol_message: Some(Box::new(waproto::whatsapp::message::ProtocolMessage { - edited_message: Some(Box::new(waproto::whatsapp::Message::default())), + protocol_message: MessageField::some(waproto::whatsapp::message::ProtocolMessage { + edited_message: MessageField::some(waproto::whatsapp::Message::default()), ..Default::default() - })), + }), ..Default::default() }; assert_eq!( @@ -594,17 +594,15 @@ mod tests { #[test] fn infer_from_message_message_edit_sender() { let msg = waproto::whatsapp::Message { - protocol_message: Some(Box::new(waproto::whatsapp::message::ProtocolMessage { - key: Some(waproto::whatsapp::MessageKey { + protocol_message: MessageField::some(waproto::whatsapp::message::ProtocolMessage { + key: MessageField::some(waproto::whatsapp::MessageKey { from_me: Some(true), ..Default::default() }), - r#type: Some( - waproto::whatsapp::message::protocol_message::Type::MessageEdit as i32, - ), - edited_message: Some(Box::new(waproto::whatsapp::Message::default())), + r#type: Some(waproto::whatsapp::message::protocol_message::Type::MESSAGE_EDIT), + edited_message: MessageField::some(waproto::whatsapp::Message::default()), ..Default::default() - })), + }), ..Default::default() }; assert_eq!( @@ -625,20 +623,20 @@ mod tests { #[test] fn infer_from_message_unwraps_neutral_wrappers() { let inner_revoke = waproto::whatsapp::Message { - protocol_message: Some(Box::new(waproto::whatsapp::message::ProtocolMessage { - key: Some(waproto::whatsapp::MessageKey { + protocol_message: MessageField::some(waproto::whatsapp::message::ProtocolMessage { + key: MessageField::some(waproto::whatsapp::MessageKey { from_me: Some(false), ..Default::default() }), - r#type: Some(waproto::whatsapp::message::protocol_message::Type::Revoke as i32), + r#type: Some(waproto::whatsapp::message::protocol_message::Type::REVOKE), ..Default::default() - })), + }), ..Default::default() }; let wrapped = waproto::whatsapp::Message { - ephemeral_message: Some(Box::new(waproto::whatsapp::message::FutureProofMessage { - message: Some(Box::new(inner_revoke)), - })), + ephemeral_message: MessageField::some(waproto::whatsapp::message::FutureProofMessage { + message: MessageField::some(inner_revoke), + }), ..Default::default() }; assert_eq!( @@ -648,22 +646,26 @@ mod tests { // Same for pin wrapped in view_once and device_sent (double nesting). let inner_pin = waproto::whatsapp::Message { - pin_in_chat_message: Some(Box::default()), + pin_in_chat_message: MessageField::some( + waproto::whatsapp::message::PinInChatMessage::default(), + ), ..Default::default() }; let wrapped_pin = waproto::whatsapp::Message { - device_sent_message: Some(Box::new(waproto::whatsapp::message::DeviceSentMessage { - destination_jid: Some(String::new()), - message: Some(Box::new(waproto::whatsapp::Message { - view_once_message: Some(Box::new( - waproto::whatsapp::message::FutureProofMessage { - message: Some(Box::new(inner_pin)), - }, - )), + device_sent_message: MessageField::some( + waproto::whatsapp::message::DeviceSentMessage { + destination_jid: Some(String::new()), + message: MessageField::some(waproto::whatsapp::Message { + view_once_message: MessageField::some( + waproto::whatsapp::message::FutureProofMessage { + message: MessageField::some(inner_pin), + }, + ), + ..Default::default() + }), ..Default::default() - })), - ..Default::default() - })), + }, + ), ..Default::default() }; assert_eq!( diff --git a/wacore/src/usync.rs b/wacore/src/usync.rs index b9be8d07d..039510cb2 100644 --- a/wacore/src/usync.rs +++ b/wacore/src/usync.rs @@ -217,19 +217,18 @@ mod tests { /// /// Build dummy ADV signed key index bytes for tests. fn build_test_key_index_bytes(device_ids: &[u16]) -> Vec { - use prost::Message; + use buffa::Message; let valid_indexes: Vec = device_ids.iter().map(|&id| id as u32).collect(); - let key_index = waproto::whatsapp::AdvKeyIndexList { + let key_index = waproto::whatsapp::ADVKeyIndexList { raw_id: Some(1), timestamp: Some(1000), current_index: Some(valid_indexes.iter().copied().max().unwrap_or(0)), valid_indexes, - account_type: None, + ..Default::default() }; - let signed = waproto::whatsapp::AdvSignedKeyIndexList { + let signed = waproto::whatsapp::ADVSignedKeyIndexList { details: Some(key_index.encode_to_vec()), - account_signature: None, - account_signature_key: None, + ..Default::default() }; signed.encode_to_vec() } diff --git a/wacore/src/voip/mlow/smpl_cc_tables.rs b/wacore/src/voip/mlow/smpl_cc_tables.rs index 906fa6998..90f06a90e 100644 --- a/wacore/src/voip/mlow/smpl_cc_tables.rs +++ b/wacore/src/voip/mlow/smpl_cc_tables.rs @@ -200,33 +200,7 @@ const NUM_RUNLEN_CMFS: usize = 20; // SMPL_MAX_SF_LEN(160) / RUNLENGTH_STEP const SPLIT_NUM_TABLES: usize = SMPL_MAX_PULSES_PER_SF * 4 - 1; // num_pulses 1..160 /// On-disk packed seed (`tables.proto` `CcSeed`). `bytes` fields reshape row-major at build. -#[derive(Clone, PartialEq, prost::Message)] -pub(crate) struct CcSeed { - #[prost(bytes = "vec", tag = "1")] - nrgres_gain4_dcmf: Vec, // [84] - #[prost(bytes = "vec", tag = "2")] - nrgres_shape4_dcmf: Vec, // [98] - #[prost(bytes = "vec", tag = "3")] - fcbg_offset_dcmf: Vec, // [3][4][176] - #[prost(bytes = "vec", tag = "4")] - acbgains_hr_dcmf: Vec, // [17][16] - #[prost(bytes = "vec", tag = "5")] - fcbgains_v_dcmf: Vec, // [34] - #[prost(bytes = "vec", tag = "6")] - fcbgains_v_delta_dcmf: Vec, // [67] - #[prost(sint32, repeated, tag = "7")] - acbgains_cb_hr_q14: Vec, // [16][2] - #[prost(uint32, tag = "8")] - gain_recon_base: u32, - #[prost(bytes = "vec", tag = "9")] - gain_recon: Vec, // gain-reconstruction int16 rodata - #[prost(bytes = "vec", tag = "10")] - n_pulses_dcmf_bgn: Vec, // [17] - #[prost(bytes = "vec", tag = "11")] - n_pulses_dcmf_uv: Vec, // [33] - #[prost(bytes = "vec", tag = "12")] - n_pulses_dcmf_v: Vec, // [33] -} +pub(crate) use super::smpl_tables_blob::tables::CcSeed; /// Runtime tables. CDFs are `u16` (the integer cmf fits 16 bits) to feed `decode_cdf`/`encode_cdf` /// directly, byte-identical to the old heap u16 reads. The accessor takes the LOGICAL index. @@ -331,7 +305,7 @@ static TABLES: OnceLock = OnceLock::new(); pub(crate) fn load_cc_tables() -> &'static CcTables { TABLES.get_or_init(|| { let seed: CcSeed = - super::smpl_tables_blob::load_blob_prost(include_bytes!("testdata/cc_seed.bin")); + super::smpl_tables_blob::load_blob_buffa(include_bytes!("testdata/cc_seed.bin")); seed.build() }) } diff --git a/wacore/src/voip/mlow/smpl_lsf_seed.rs b/wacore/src/voip/mlow/smpl_lsf_seed.rs index 390b22786..1c64078c2 100644 --- a/wacore/src/voip/mlow/smpl_lsf_seed.rs +++ b/wacore/src/voip/mlow/smpl_lsf_seed.rs @@ -36,44 +36,11 @@ const ST2_QLVLS_SCALE: f32 = 0.0034478905; const QSTEP_COND_MULT: f32 = 0.9; // LSF_QSTEP_COND_MULT /// On-disk packed LSF ROM (flat row-major; `tables.proto` `LsfSeed`). Reshaped before expansion. -#[derive(Clone, PartialEq, prost::Message)] -pub(crate) struct LsfSeed { - #[prost(bytes = "vec", tag = "1")] - rot_8: Vec, // [2][16][16][16] - #[prost(bytes = "vec", tag = "2")] - rot_cond_8: Vec, // [2][2][16][16] - #[prost(bytes = "vec", tag = "3")] - st2_all_qlvls_8: Vec, // [9593] - #[prost(bytes = "vec", tag = "4")] - st2_all_qlvl_dcmfs: Vec, // [9593] - #[prost(bytes = "vec", tag = "5")] - st2_min_qi: Vec, // [2][2][17][16] i8 - #[prost(bytes = "vec", tag = "6")] - st2_max_qi: Vec, // [2][2][17][16] i8 - #[prost(uint32, repeated, tag = "7")] - cb_16: Vec, // [2][16][16] - #[prost(uint32, repeated, tag = "8")] - cinv_16: Vec, // [2][136] - #[prost(uint32, repeated, tag = "9")] - cmf: Vec, // [2][17] - #[prost(uint32, repeated, tag = "10")] - cmf_cond: Vec, // [2][18] - #[prost(uint32, repeated, tag = "11")] - lsf_sel: Vec, // [3][3] - #[prost(uint32, repeated, tag = "12")] - lsf_extra: Vec, // [3] - #[prost(float, repeated, tag = "13")] - mean: Vec, // [2][16] - #[prost(float, repeated, tag = "14")] - min_dist: Vec, // [2][17] - #[prost(float, repeated, tag = "15")] - reg_cond: Vec, // [2] - #[prost(float, repeated, tag = "16")] - qstep: Vec, // [2][2] - // grid16_w/alpha/matrices and centroids16/matrices16 are not stored: the synth grid16 tables are - // derived at load (grid16_w = mean[1-v], grid16_alpha = reg_cond, grid16_matrices = unpack8(rot_cond_8)), - // and the grid==16 centroids/matrices rows are never read (grid==16 returns before indexing them). -} +/// +/// grid16_w/alpha/matrices and centroids16/matrices16 are not stored: the synth grid16 tables are +/// derived at load (grid16_w = mean[1-v], grid16_alpha = reg_cond, grid16_matrices = unpack8(rot_cond_8)), +/// and the grid==16 centroids/matrices rows are never read (grid==16 returns before indexing them). +pub(crate) use super::smpl_tables_blob::tables::LsfSeed; /// The packed ROM reshaped into the nested arrays the expansion indexes. Outer index `[voiced]`. struct LsfSeedNested { @@ -598,7 +565,7 @@ static LSF_BUILT: OnceLock = OnceLock::new(); pub(crate) fn lsf_built() -> &'static LsfBuilt { LSF_BUILT.get_or_init(|| { let seed: LsfSeed = - super::smpl_tables_blob::load_blob_prost(include_bytes!("testdata/lsf_seed.bin")); + super::smpl_tables_blob::load_blob_buffa(include_bytes!("testdata/lsf_seed.bin")); seed.build() }) } @@ -751,7 +718,7 @@ mod tests { /// computed synth tables from the seed fields they're derived from. #[test] fn lsf_seed_grid16_derivation() { - let seed: LsfSeed = super::super::smpl_tables_blob::load_blob_prost(include_bytes!( + let seed: LsfSeed = super::super::smpl_tables_blob::load_blob_buffa(include_bytes!( "testdata/lsf_seed.bin" )); let nested = seed.reshape(); diff --git a/wacore/src/voip/mlow/smpl_mem.rs b/wacore/src/voip/mlow/smpl_mem.rs index 7eb2b8993..b039ea7d0 100644 --- a/wacore/src/voip/mlow/smpl_mem.rs +++ b/wacore/src/voip/mlow/smpl_mem.rs @@ -8,28 +8,10 @@ use std::sync::OnceLock; /// `tables.proto` Region. -#[derive(Clone, PartialEq, prost::Message)] -struct SmplMemRegion { - #[prost(uint32, tag = "1")] - base: u32, - #[prost(bytes = "vec", tag = "2")] - data: Vec, -} +use super::smpl_tables_blob::tables::Region as SmplMemRegion; /// `tables.proto` HeapWindow; the runtime window built by `build_smpl_mem`. -#[derive(Clone, PartialEq, prost::Message)] -pub(crate) struct SmplMem { - #[prost(message, repeated, tag = "1")] - regions: Vec, - #[prost(uint32, tag = "2")] - pub(crate) g_cc: u32, - #[prost(uint32, tag = "3")] - pub(crate) g_nrg: u32, - #[prost(uint32, tag = "4")] - pub(crate) g_pitch: u32, - #[prost(uint32, tag = "5")] - pub(crate) g_clk: u32, -} +pub(crate) use super::smpl_tables_blob::tables::HeapWindow as SmplMem; static SMPL_MEM: OnceLock = OnceLock::new(); @@ -89,7 +71,7 @@ const NUM_CONTOURS: usize = 217; /// every address the consumer reads. fn build_smpl_mem() -> SmplMem { let seed: super::smpl_pitch_seed::PitchSeed = - super::smpl_tables_blob::load_blob_prost(include_bytes!("testdata/pitch_seed.bin")); + super::smpl_tables_blob::load_blob_buffa(include_bytes!("testdata/pitch_seed.bin")); let w = seed.build_contour_window(); let mut regions = Vec::with_capacity(6); diff --git a/wacore/src/voip/mlow/smpl_pitch_enc.rs b/wacore/src/voip/mlow/smpl_pitch_enc.rs index c2da1ecc4..88b6bba59 100644 --- a/wacore/src/voip/mlow/smpl_pitch_enc.rs +++ b/wacore/src/voip/mlow/smpl_pitch_enc.rs @@ -82,7 +82,7 @@ static TABLES: OnceLock = OnceLock::new(); pub(crate) fn load_pitch_tables() -> &'static PitchTables { TABLES.get_or_init(|| { let seed: super::smpl_pitch_seed::PitchSeed = - super::smpl_tables_blob::load_blob_prost(include_bytes!("testdata/pitch_seed.bin")); + super::smpl_tables_blob::load_blob_buffa(include_bytes!("testdata/pitch_seed.bin")); seed.build() }) } diff --git a/wacore/src/voip/mlow/smpl_pitch_seed.rs b/wacore/src/voip/mlow/smpl_pitch_seed.rs index e0c9c13c1..75c3145a8 100644 --- a/wacore/src/voip/mlow/smpl_pitch_seed.rs +++ b/wacore/src/voip/mlow/smpl_pitch_seed.rs @@ -12,23 +12,7 @@ const NUM_BLOCKTRACKS: usize = 187; const PITCH_NUM_BLOCKS: usize = 9; /// On-disk packed pitch ROM (`tables.proto` `PitchSeed`). `bytes` fields reshape row-major at build. -#[derive(Clone, PartialEq, prost::Message)] -pub(crate) struct PitchSeed { - #[prost(bytes = "vec", tag = "1")] - blocksegs_bitstream: Vec, // 676; range-decoder source - #[prost(bytes = "vec", tag = "2")] - blocksegs2idx: Vec, // [217] - #[prost(bytes = "vec", tag = "3")] - blocksegs_ix: Vec, // [187][2] - #[prost(bytes = "vec", tag = "4")] - firstblock_range: Vec, // [9][2] - #[prost(bytes = "vec", tag = "5")] - blockseg_idx_dcmf: Vec, // [217] - #[prost(bytes = "vec", tag = "6")] - delta_lag_dcmfs: Vec, // [3][319] - #[prost(bytes = "vec", tag = "7")] - block_transition_dcmf: Vec, // [9][9] -} +pub(crate) use super::smpl_tables_blob::tables::PitchSeed; /// Decode a uniform symbol in `[0, N)`. fn ec_decode_uniform(dec: &mut RangeDecoder, n: u32) -> u32 { diff --git a/wacore/src/voip/mlow/smpl_tables_blob.rs b/wacore/src/voip/mlow/smpl_tables_blob.rs index 303c9a4d8..29d2e31b9 100644 --- a/wacore/src/voip/mlow/smpl_tables_blob.rs +++ b/wacore/src/voip/mlow/smpl_tables_blob.rs @@ -9,6 +9,23 @@ use std::io::Read; +/// buffa-generated types for the table schemas (`tables.proto`), produced at +/// build time into `OUT_DIR` (see `wacore/build.rs`). The `.bin` blobs decode +/// against these by field number, so the format is unchanged from the prior +/// prost-derived equivalents. +pub(crate) mod tables { + #![allow( + unused, + non_camel_case_types, + non_snake_case, + unreachable_patterns, + clippy::all, + clippy::pedantic, + clippy::nursery + )] + buffa::include_proto!("mlow.tables"); +} + /// Zlib level used by the generator. Fixed so re-running yields identical `.bin` bytes. #[cfg(test)] const GEN_ZLIB_LEVEL: u32 = 9; @@ -23,9 +40,9 @@ fn inflate(compressed: &[u8]) -> Vec { } /// Load a protobuf table from its embedded zlib blob. -pub(crate) fn load_blob_prost(compressed: &[u8]) -> T { +pub(crate) fn load_blob_buffa(compressed: &[u8]) -> T { let bytes = inflate(compressed); - T::decode(bytes.as_slice()).expect("mlow table blob must protobuf-decode") + T::decode_from_slice(bytes.as_slice()).expect("mlow table blob must protobuf-decode") } /// Zlib-compress already-encoded bytes (for callers that encode protobuf themselves). Deterministic. @@ -76,14 +93,14 @@ mod generator { // 1. LSF seed ROM: protobuf (tables.proto `LsfSeed`), then zlib. The expanded LSF tables // (synth/lsf-cb/lsf-decode) are derived from this at load. if let Some(j) = try_read_json("lsf_seed.json") { - use prost::Message; + use buffa::Message; let seed = super::super::smpl_lsf_seed::seed_from_json(&j); write_bin("lsf_seed.bin", &make_blob_raw(&seed.encode_to_vec())); } // 2. pitch seed ROM: protobuf (tables.proto `PitchSeed`), then zlib. if let Some(j) = try_read_json("pitch_seed.json") { - use prost::Message; + use buffa::Message; let seed = super::super::smpl_pitch_seed::seed_from_json(&j); write_bin("pitch_seed.bin", &make_blob_raw(&seed.encode_to_vec())); } @@ -91,7 +108,7 @@ mod generator { // 3. cc seed ROM: protobuf (tables.proto `CcSeed`), then zlib. The nrgres/gains (Group A/E) // and LTP gain (Group C) CDFs are derived from this at load. if let Some(j) = try_read_json("cc_seed.json") { - use prost::Message; + use buffa::Message; let seed = super::super::smpl_cc_tables::seed_from_json(&j); write_bin("cc_seed.bin", &make_blob_raw(&seed.encode_to_vec())); } diff --git a/wacore/src/voip/mlow/tables.desc b/wacore/src/voip/mlow/tables.desc new file mode 100644 index 000000000..f4638acab --- /dev/null +++ b/wacore/src/voip/mlow/tables.desc @@ -0,0 +1,57 @@ + + + tables.proto mlow.tables" + +HeapWindow- +regions ( 2.mlow.tables.RegionRregions +g_cc ( RgCc +g_nrg ( RgNrg +g_pitch ( RgPitch +g_clk ( RgClk"0 +Region +base ( Rbase +data ( Rdata" +LsfSeed +rot_8 ( Rrot8 + +rot_cond_8 ( RrotCond8% +st2_all_qlvls_8 ( R st2AllQlvls8+ +st2_all_qlvl_dcmfs ( Rst2AllQlvlDcmfs + +st2_min_qi ( Rst2MinQi + +st2_max_qi ( Rst2MaxQi +cb_16 ( Rcb16 +cinv_16 ( Rcinv16 +cmf ( Rcmf +cmf_cond + ( RcmfCond +lsf_sel ( RlsfSel + lsf_extra ( RlsfExtra +mean (Rmean +min_dist (RminDist +reg_cond (RregCond +qstep (Rqstep" + PitchSeed/ +blocksegs_bitstream ( RblocksegsBitstream$ + blocksegs2idx ( R blocksegs2idx! + blocksegs_ix ( R blocksegsIx) +firstblock_range ( RfirstblockRange* +blockseg_idx_dcmf ( RblocksegIdxDcmf& +delta_lag_dcmfs ( R deltaLagDcmfs2 +block_transition_dcmf ( RblockTransitionDcmf" +CcSeed* +nrgres_gain4_dcmf ( RnrgresGain4Dcmf, +nrgres_shape4_dcmf ( RnrgresShape4Dcmf( +fcbg_offset_dcmf ( RfcbgOffsetDcmf( +acbgains_hr_dcmf ( RacbgainsHrDcmf& +fcbgains_v_dcmf ( R fcbgainsVDcmf1 +fcbgains_v_delta_dcmf ( RfcbgainsVDeltaDcmf+ +acbgains_cb_hr_q14 (RacbgainsCbHrQ14& +gain_recon_base ( R gainReconBase + +gain_recon ( R gainRecon) +n_pulses_dcmf_bgn + ( RnPulsesDcmfBgn' +n_pulses_dcmf_uv ( R nPulsesDcmfUv% +n_pulses_dcmf_v ( R nPulsesDcmfVbproto3 \ No newline at end of file diff --git a/wacore/src/voip/mlow/tables.desc.sha256 b/wacore/src/voip/mlow/tables.desc.sha256 new file mode 100644 index 000000000..1830f93bb --- /dev/null +++ b/wacore/src/voip/mlow/tables.desc.sha256 @@ -0,0 +1,2 @@ +proto 9ebaa32a5d6966088345c3a10c3b318b53959e5fe1d3cb7fc352337c11b2f631 +desc fcbce24249770cd137f5c1b9bfa9c7c77dbf2497db0cd18343c767d878a9fda6 diff --git a/wacore/tests/appstate_external_mutations_test.rs b/wacore/tests/appstate_external_mutations_test.rs index feaccbf4b..f86f2a107 100644 --- a/wacore/tests/appstate_external_mutations_test.rs +++ b/wacore/tests/appstate_external_mutations_test.rs @@ -4,7 +4,7 @@ //! 1. Patches have external_mutations that need to be downloaded //! 2. REMOVE mutations reference entries we don't have locally (hasMissingRemove) -use prost::Message; +use buffa::Message; use wacore::appstate::WAPATCH_INTEGRITY; use wacore::appstate::hash::{HashState, generate_content_mac}; use wacore::appstate::keys::expand_app_state_keys; @@ -18,16 +18,22 @@ fn make_mutation( value_blob: Option>, ) -> wa::SyncdMutation { wa::SyncdMutation { - operation: Some(op as i32), - record: Some(wa::SyncdRecord { - index: Some(wa::SyncdIndex { + operation: Some(op), + record: wa::SyncdRecord { + index: wa::SyncdIndex { blob: Some(index_mac), - }), - value: value_blob.map(|b| wa::SyncdValue { blob: Some(b) }), - key_id: Some(wa::KeyId { + } + .into(), + value: match value_blob { + Some(b) => wa::SyncdValue { blob: Some(b) }.into(), + None => Default::default(), + }, + key_id: wa::KeyId { id: Some(key_id.to_vec()), - }), - }), + } + .into(), + } + .into(), } } @@ -40,7 +46,7 @@ fn create_value_blob( let mut content = iv.to_vec(); content.extend_from_slice(&ciphertext); let value_mac = generate_content_mac( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &content, key_id, &keys.value_mac, @@ -58,7 +64,7 @@ fn test_has_missing_remove_flag_set_on_remove_without_previous_value() { // Create a REMOVE mutation for an entry that doesn't exist locally let mutations = vec![make_mutation( - wa::syncd_mutation::SyncdOperation::Remove, + wa::syncd_mutation::SyncdOperation::REMOVE, key_id, index_mac, None, @@ -86,7 +92,7 @@ fn test_has_missing_remove_flag_not_set_on_set_without_previous_value() { // Create a SET mutation (new entry, no previous value) let mutations = vec![make_mutation( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, key_id, index_mac, Some(value_blob), @@ -110,7 +116,7 @@ fn test_has_missing_remove_flag_not_set_when_previous_value_exists() { // Create a REMOVE mutation for an entry that DOES exist locally let mutations = vec![make_mutation( - wa::syncd_mutation::SyncdOperation::Remove, + wa::syncd_mutation::SyncdOperation::REMOVE, key_id, index_mac.clone(), None, @@ -148,7 +154,7 @@ fn test_lthash_diverges_on_missing_remove() { let mut state = HashState::default(); let set_mutation = make_mutation( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, key_id, index_mac_1.clone(), Some(value_blob_1), @@ -168,7 +174,7 @@ fn test_lthash_diverges_on_missing_remove() { // Now simulate a REMOVE for an entry we DON'T have (index_mac_2) let index_mac_2 = vec![2u8; 32]; let remove_mutation = make_mutation( - wa::syncd_mutation::SyncdOperation::Remove, + wa::syncd_mutation::SyncdOperation::REMOVE, key_id, index_mac_2, None, @@ -195,14 +201,14 @@ fn test_external_mutations_decode_from_syncd_mutations() { let keys = expand_app_state_keys(&master_key); let mutation1 = make_mutation( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, key_id, vec![1u8; 32], Some(create_value_blob(&keys, key_id)), ); let mutation2 = make_mutation( - wa::syncd_mutation::SyncdOperation::Remove, + wa::syncd_mutation::SyncdOperation::REMOVE, key_id, vec![2u8; 32], None, @@ -216,16 +222,16 @@ fn test_external_mutations_decode_from_syncd_mutations() { let encoded = syncd_mutations.encode_to_vec(); // Decode back - let decoded = wa::SyncdMutations::decode(encoded.as_slice()).expect("should decode"); + let decoded = wa::SyncdMutations::decode_from_slice(&encoded).expect("should decode"); assert_eq!(decoded.mutations.len(), 2); assert_eq!( decoded.mutations[0].operation, - Some(wa::syncd_mutation::SyncdOperation::Set as i32) + Some(wa::syncd_mutation::SyncdOperation::SET) ); assert_eq!( decoded.mutations[1].operation, - Some(wa::syncd_mutation::SyncdOperation::Remove as i32) + Some(wa::syncd_mutation::SyncdOperation::REMOVE) ); } @@ -244,17 +250,13 @@ fn test_validate_patch_macs_rejects_on_has_missing_remove() { // Create a patch with a snapshot_mac that won't match our state let patch = wa::SyncdPatch { - version: Some(wa::SyncdVersion { version: Some(2) }), - mutations: vec![], - external_mutations: None, + version: wa::SyncdVersion { version: Some(2) }.into(), snapshot_mac: Some(vec![0u8; 32]), // This won't match our computed MAC - patch_mac: None, - key_id: Some(wa::KeyId { + key_id: wa::KeyId { id: Some(key_id.to_vec()), - }), - exit_code: None, - device_index: None, - client_debug_data: None, + } + .into(), + ..Default::default() }; let result_without_flag = @@ -288,7 +290,7 @@ fn test_mixed_set_and_remove_with_missing_remove() { let value_mac_known = value_blob_known[value_blob_known.len() - 32..].to_vec(); let set_known = make_mutation( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, key_id, index_mac_known.clone(), Some(value_blob_known), @@ -308,19 +310,19 @@ fn test_mixed_set_and_remove_with_missing_remove() { let mutations = vec![ make_mutation( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, key_id, index_mac_new, Some(value_blob_new), ), make_mutation( - wa::syncd_mutation::SyncdOperation::Remove, + wa::syncd_mutation::SyncdOperation::REMOVE, key_id, index_mac_known.clone(), None, ), make_mutation( - wa::syncd_mutation::SyncdOperation::Remove, + wa::syncd_mutation::SyncdOperation::REMOVE, key_id, index_mac_unknown, None, diff --git a/wacore/tests/appstate_mac_test.rs b/wacore/tests/appstate_mac_test.rs index 6c333eb4f..963f44d0a 100644 --- a/wacore/tests/appstate_mac_test.rs +++ b/wacore/tests/appstate_mac_test.rs @@ -5,15 +5,18 @@ use waproto::whatsapp as wa; // Helper to build a SyncdRecord with provided key id and value blob (iv+ciphertext+valuemac appended later in logic) fn make_record(key_id: &[u8], value_with_mac: Vec, index_mac: Vec) -> wa::SyncdRecord { wa::SyncdRecord { - index: Some(wa::SyncdIndex { + index: wa::SyncdIndex { blob: Some(index_mac), - }), - value: Some(wa::SyncdValue { + } + .into(), + value: wa::SyncdValue { blob: Some(value_with_mac), - }), - key_id: Some(wa::KeyId { + } + .into(), + key_id: wa::KeyId { id: Some(key_id.to_vec()), - }), + } + .into(), } } @@ -35,7 +38,7 @@ fn snapshot_and_patch_mac_roundtrip() { let mut content1 = iv.to_vec(); content1.extend_from_slice(&ciphertext1); let value_mac1 = generate_content_mac( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &content1, key_id, &keys.value_mac, @@ -47,7 +50,7 @@ fn snapshot_and_patch_mac_roundtrip() { let mut content2 = iv.to_vec(); content2.extend_from_slice(&ciphertext2); let value_mac2 = generate_content_mac( - wa::syncd_mutation::SyncdOperation::Set, + wa::syncd_mutation::SyncdOperation::SET, &content2, key_id, &keys.value_mac, @@ -60,12 +63,12 @@ fn snapshot_and_patch_mac_roundtrip() { let index_mac2 = vec![8u8; 32]; let mutation1 = wa::SyncdMutation { - operation: Some(wa::syncd_mutation::SyncdOperation::Set as i32), - record: Some(make_record(key_id, value_blob1.clone(), index_mac1.clone())), + operation: Some(wa::syncd_mutation::SyncdOperation::SET), + record: make_record(key_id, value_blob1.clone(), index_mac1.clone()).into(), }; let mutation2 = wa::SyncdMutation { - operation: Some(wa::syncd_mutation::SyncdOperation::Set as i32), - record: Some(make_record(key_id, value_blob2.clone(), index_mac2.clone())), + operation: Some(wa::syncd_mutation::SyncdOperation::SET), + record: make_record(key_id, value_blob2.clone(), index_mac2.clone()).into(), }; let mutations = vec![mutation1.clone(), mutation2.clone()]; @@ -80,19 +83,17 @@ fn snapshot_and_patch_mac_roundtrip() { // Now build a patch referencing snapshot MAC and containing same mutations to compute patch MAC let patch = wa::SyncdPatch { - version: Some(wa::SyncdVersion { + version: wa::SyncdVersion { version: Some(state.version), - }), + } + .into(), mutations: mutations.clone(), - external_mutations: None, snapshot_mac: Some(snapshot_mac.clone()), - patch_mac: None, - key_id: Some(wa::KeyId { + key_id: wa::KeyId { id: Some(key_id.to_vec()), - }), - exit_code: None, - device_index: None, - client_debug_data: None, + } + .into(), + ..Default::default() }; let patch_mac = generate_patch_mac(&patch, "regular_high", &keys.patch_mac, state.version); @@ -107,8 +108,8 @@ fn snapshot_and_patch_mac_roundtrip() { // Mutate a value MAC and ensure patch MAC changes let mut altered_patch = patch.clone(); - if let Some(rec) = altered_patch.mutations[0].record.as_mut() - && let Some(val) = rec.value.as_mut() + if let Some(rec) = altered_patch.mutations[0].record.as_option_mut() + && let Some(val) = rec.value.as_option_mut() && let Some(blob) = val.blob.as_mut() { let last = blob.len() - 1; diff --git a/waproto/Cargo.toml b/waproto/Cargo.toml index 76dd74775..a88ebbadf 100644 --- a/waproto/Cargo.toml +++ b/waproto/Cargo.toml @@ -16,17 +16,29 @@ ignored = ["serde"] default = [] serde-deserialize = [] serde-snake-case = ["serde-deserialize"] +# Enums (de)serialize as their numeric repr instead of the variant name — what +# the JS bridge / camelCase serializer expects. +serde-enum-repr = ["dep:serde_repr"] [dependencies] -prost = { workspace = true, features = ["derive"] } +buffa = { workspace = true } +bytes = { workspace = true } serde = { workspace = true } +serde_repr = { version = "0.1", optional = true } [build-dependencies] +# buffa-build does the codegen; buffa-descriptor decodes+re-encodes the committed +# camelCase descriptor so build.rs can snake_case field names for the Rust API +# (the proto file stays in upstream/whatspec camelCase form); heck does the case +# conversion; sha2 powers the descriptor-freshness guard. +buffa = { workspace = true } +buffa-build = { workspace = true } +buffa-descriptor = { workspace = true } heck = { workspace = true } -prost = { workspace = true } -prost-build = { workspace = true } -prost-types = { workspace = true } sha2 = { workspace = true } +[dev-dependencies] +serde_json = "1" + [lints] workspace = true diff --git a/waproto/build.rs b/waproto/build.rs index 49d1cbc30..b3bd28ae3 100644 --- a/waproto/build.rs +++ b/waproto/build.rs @@ -1,14 +1,29 @@ //! # Updating the proto //! -//! 1. Edit `src/whatsapp.proto`. +//! 1. Edit `src/whatsapp.proto` (kept in the upstream / whatspec camelCase +//! form — do NOT hand-rename fields to snake_case). //! 2. Optional: format with `buf format src/whatsapp.proto -w`. //! 3. Regenerate the descriptor: `scripts/regenerate-proto-desc.sh` -//! (wraps `protoc --descriptor_set_out=src/whatsapp.desc ...`). -//! 4. `cargo build` — this script consumes `whatsapp.desc` and writes -//! `whatsapp.rs` + `tags.rs` to `OUT_DIR`. Consumers never need `protoc` -//! installed; only editors of the proto do. +//! (wraps `protoc --descriptor_set_out=src/whatsapp.desc …`). +//! 4. `cargo build` — this script consumes `whatsapp.desc`, snake_cases the +//! field names for the Rust API, and writes `whatsapp.rs` to `OUT_DIR`. +//! Consumers never need `protoc`; only editors of the proto do. +//! +//! ## Why the descriptor is snake_cased at build time +//! +//! buffa generates Rust field idents verbatim from the proto field names, so a +//! camelCase proto would yield camelCase Rust fields. To keep the proto in the +//! upstream camelCase form (so it can be regenerated from whatspec untouched) +//! while exposing the prost-style snake_case Rust API the codebase uses, this +//! script decodes the committed descriptor, snake_cases every message/oneof +//! field name, and feeds the rewritten descriptor to buffa. Wire format is +//! unaffected (the wire keys on field numbers, not names); message/enum type +//! names and enum value names are left as-is. Drop this once buffa grows a +//! native idiomatic-field-names option (anthropics/buffa#256). -use prost::Message as _; +use buffa::Message as _; +use buffa_descriptor::generated::descriptor::{DescriptorProto, FileDescriptorSet}; +use heck::ToSnakeCase as _; fn main() -> std::io::Result<()> { // Rerun on desc change (new codegen) and proto change (so the staleness @@ -20,158 +35,181 @@ fn main() -> std::io::Result<()> { ensure_proto_descriptor_hash()?; - let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").ok_or_else(|| { - std::io::Error::other("OUT_DIR not set (cargo always sets it for build scripts)") - })?); + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR must be set by cargo"); + let out_path = std::path::PathBuf::from(&out_dir); - let fds = - prost_types::FileDescriptorSet::decode(std::fs::read("src/whatsapp.desc")?.as_slice()) - .map_err(std::io::Error::other)?; + // Rewrite the committed camelCase descriptor into a snake_case-field one for + // buffa codegen (see module docs). buffa reads the rewritten copy from OUT_DIR. + let snake_desc = out_path.join("whatsapp.snake.desc"); + snake_case_descriptor_fields("src/whatsapp.desc", &snake_desc)?; - let mut config = prost_build::Config::new(); - - // Serialize always; Deserialize only for WASM bridge (halves serde codegen). - config.type_attribute(".", "#[derive(serde::Serialize)]"); - config.type_attribute( - ".", - "#[cfg_attr(feature = \"serde-deserialize\", derive(serde::Deserialize))]", - ); - // Default missing fields to match protobuf semantics (structs only). - config.message_attribute( - ".", - "#[cfg_attr(feature = \"serde-deserialize\", serde(default))]", - ); - - // Accept snake_case on deserialization for WASM bridge enum variants. - config.type_attribute( - ".", - "#[cfg_attr(feature = \"serde-snake-case\", serde(rename_all(deserialize = \"snake_case\")))]", - ); + // Emit the wire-tag consts (field numbers) for hand-written partial decoders. + // Generated from the original descriptor; the shouty/snake transforms are + // unaffected by the camelCase->snake_case field rename. + let fds = FileDescriptorSet::decode_from_slice(&std::fs::read("src/whatsapp.desc")?) + .map_err(std::io::Error::other)?; + generate_tags(&fds, &out_path.join("tags.rs"))?; - // O(1)-clone Bytes for hot-path crypto structures instead of Vec. - config.bytes([ - ".whatsapp.SessionStructure.Chain.ChainKey", - ".whatsapp.SessionStructure.Chain.MessageKey", - ".whatsapp.SenderKeyStateStructure.SenderChainKey", - ".whatsapp.SenderKeyStateStructure.SenderMessageKey", - ".whatsapp.SenderKeyStateStructure.SenderSigningKey", - ]); + buffa_build::Config::new() + .descriptor_set(&snake_desc) + .files(&["whatsapp.proto"]) + // Box every singular message field. buffa defaults them to inline; for + // WhatsApp's deep, many-optional-field messages (every message variant + // is its own inline slot) that makes size_of explode recursively, + // turning decode and Vec growth into large struct memcpys. Box keeps + // the structs pointer-sized. + .box_type(buffa_build::PointerRepr::Box) + // Messages + oneofs: serde over the struct/oneof shape. Serialize always; + // Deserialize only for the WASM bridge (halves serde codegen). + .message_attribute(".", "#[derive(serde::Serialize)]") + .message_attribute( + ".", + "#[cfg_attr(feature = \"serde-deserialize\", derive(serde::Deserialize))]", + ) + .message_attribute( + ".", + "#[cfg_attr(feature = \"serde-deserialize\", serde(default))]", + ) + .oneof_attribute(".", "#[derive(serde::Serialize)]") + .oneof_attribute( + ".", + "#[cfg_attr(feature = \"serde-deserialize\", derive(serde::Deserialize))]", + ) + // Enums: variant name by default; numeric repr (prost parity, JS bridge) + // under `serde-enum-repr`. Targeting enums separately from oneofs needs + // buffa's enum_attribute/oneof_attribute split. + .enum_attribute( + ".", + "#[cfg_attr(not(feature = \"serde-enum-repr\"), derive(serde::Serialize))]", + ) + .enum_attribute( + ".", + "#[cfg_attr(feature = \"serde-enum-repr\", derive(serde_repr::Serialize_repr))]", + ) + .enum_attribute( + ".", + "#[cfg_attr(all(feature = \"serde-deserialize\", not(feature = \"serde-enum-repr\")), derive(serde::Deserialize))]", + ) + .enum_attribute( + ".", + "#[cfg_attr(all(feature = \"serde-deserialize\", feature = \"serde-enum-repr\"), derive(serde_repr::Deserialize_repr))]", + ) + // buffa emits SCREAMING_SNAKE variant names (CHROME, MESSAGE_EDIT), so + // `lowercase` yields the intended chrome / message_edit. `snake_case` + // would insert a separator before every char (c_h_r_o_m_e). + .enum_attribute( + ".", + "#[cfg_attr(all(feature = \"serde-snake-case\", not(feature = \"serde-enum-repr\")), serde(rename_all(deserialize = \"lowercase\")))]", + ) + // O(1)-clone Bytes for hot-path crypto structures instead of Vec. + .use_bytes_type_in(&[ + ".whatsapp.SessionStructure.Chain.ChainKey", + ".whatsapp.SessionStructure.Chain.MessageKey", + ".whatsapp.SenderKeyStateStructure.SenderChainKey", + ".whatsapp.SenderKeyStateStructure.SenderMessageKey", + ".whatsapp.SenderKeyStateStructure.SenderSigningKey", + ]) + // Bytes fields lack serde support; skip them (internal crypto state). + .field_attribute( + ".whatsapp.SessionStructure.Chain.ChainKey.key", + "#[serde(skip)]", + ) + .field_attribute( + ".whatsapp.SessionStructure.Chain.MessageKey.cipher_key", + "#[serde(skip)]", + ) + .field_attribute( + ".whatsapp.SessionStructure.Chain.MessageKey.mac_key", + "#[serde(skip)]", + ) + .field_attribute( + ".whatsapp.SessionStructure.Chain.MessageKey.iv", + "#[serde(skip)]", + ) + .field_attribute( + ".whatsapp.SenderKeyStateStructure.SenderChainKey.seed", + "#[serde(skip)]", + ) + .field_attribute( + ".whatsapp.SenderKeyStateStructure.SenderMessageKey.seed", + "#[serde(skip)]", + ) + .field_attribute( + ".whatsapp.SenderKeyStateStructure.SenderSigningKey.public", + "#[serde(skip)]", + ) + .field_attribute( + ".whatsapp.SenderKeyStateStructure.SenderSigningKey.private", + "#[serde(skip)]", + ) + // We control both encoder and decoder — no need to preserve unknown + // fields. Disabling removes __buffa_unknown_fields from every struct, + // eliminating allocation/drop overhead in nested types like + // SessionStructure (chains × message keys). + .preserve_unknown_fields(false) + // Generate view types for zero-copy decoding. + .generate_views(true) + .out_dir(&out_path) + .compile() + .map_err(|e| std::io::Error::other(e.to_string()))?; - // Boxed: large (and mostly absent-on-the-wire) submessages whose inline - // form makes prost's repeated-field decode memcpy-bound — every element - // pays push(default) plus Vec-growth copies of the full struct size. - config.boxed(".whatsapp.HistorySyncMsg.message"); - config.boxed(".whatsapp.WebMessageInfo.message"); - config.boxed(".whatsapp.WebMessageInfo.statusMentionMessageInfo"); - config.boxed(".whatsapp.Message.messageContextInfo"); + Ok(()) +} - // Box the remaining inline message-typed fields so `wa::Message` — a union - // of ~110 content variants of which exactly one is ever set — stops paying - // for all of them inline. prost already boxes the variants in recursion - // cycles; these are the rest. Shrinking the struct makes every clone, - // decode, and `Arc` event cheaper to move and hold. - for field in [ - "bcallMessage", - "callLogMesssage", - "cancelPaymentRequestMessage", - "chat", - "conditionalRevealMessage", - "declinePaymentRequestMessage", - "encCommentMessage", - "encEventResponseMessage", - "encReactionMessage", - "groupRootKeyShare", - "invoiceMessage", - "keepInChatMessage", - "paymentInviteMessage", - "paymentReminderMessage", - "pinInChatMessage", - "placeholderMessage", - "pollAddOptionMessage", - "pollUpdateMessage", - "questionResponseMessage", - "reactionMessage", - "rootSecretDistributeMessage", - "scheduledCallCreationMessage", - "scheduledCallEditMessage", - "secretEncryptedMessage", - "statusNotificationMessage", - "statusQuestionAnswerMessage", - "statusQuotedMessage", - "statusStickerInteractionMessage", - "stickerSyncRmrMessage", - ] { - config.boxed(format!(".whatsapp.Message.{field}").as_str()); +/// Decode the committed (camelCase-field) descriptor, snake_case every message +/// and oneof field name, and write the rewritten descriptor to `output`. Field +/// numbers — the only thing the wire format depends on — are untouched, so this +/// is wire-compatible; it only changes the Rust field idents buffa generates. +fn snake_case_descriptor_fields(input: &str, output: &std::path::Path) -> std::io::Result<()> { + let mut fds = FileDescriptorSet::decode_from_slice(&std::fs::read(input)?) + .map_err(std::io::Error::other)?; + for file in &mut fds.file { + for message in &mut file.message_type { + snake_case_message_fields(message); + } } + std::fs::write(output, fds.encode_to_vec()) +} - // Bytes fields lack serde support; skip them (internal crypto state). - config.field_attribute( - ".whatsapp.SessionStructure.Chain.ChainKey.key", - "#[serde(skip)]", - ); - config.field_attribute( - ".whatsapp.SessionStructure.Chain.MessageKey.cipherKey", - "#[serde(skip)]", - ); - config.field_attribute( - ".whatsapp.SessionStructure.Chain.MessageKey.macKey", - "#[serde(skip)]", - ); - config.field_attribute( - ".whatsapp.SessionStructure.Chain.MessageKey.iv", - "#[serde(skip)]", - ); - config.field_attribute( - ".whatsapp.SenderKeyStateStructure.SenderChainKey.seed", - "#[serde(skip)]", - ); - config.field_attribute( - ".whatsapp.SenderKeyStateStructure.SenderMessageKey.seed", - "#[serde(skip)]", - ); - config.field_attribute( - ".whatsapp.SenderKeyStateStructure.SenderSigningKey.public", - "#[serde(skip)]", - ); - config.field_attribute( - ".whatsapp.SenderKeyStateStructure.SenderSigningKey.private", - "#[serde(skip)]", - ); - - config.out_dir(&out_dir); - config.compile_fds(fds.clone())?; - - generate_tags(&fds, &out_dir.join("tags.rs")) +fn snake_case_message_fields(message: &mut DescriptorProto) { + for field in &mut message.field { + if let Some(name) = &field.name { + let snake = name.to_snake_case(); + // Keep json_name in sync so buffa's serde/proto_name matches the ident. + field.json_name = Some(snake.clone()); + field.name = Some(snake); + } + } + for oneof in &mut message.oneof_decl { + if let Some(name) = &oneof.name { + oneof.name = Some(name.to_snake_case()); + } + } + for nested in &mut message.nested_type { + snake_case_message_fields(nested); + } } -/// Generate `tags.rs`: one module per message carrying a `u32` const per -/// field with its wire tag, straight from the descriptor. Hand-written -/// partial decoders reference these consts (or compile-time assert against -/// them), so a schema change that renumbers, renames or removes a field breaks -/// the build instead of silently desyncing. -fn generate_tags( - fds: &prost_types::FileDescriptorSet, - out_path: &std::path::Path, -) -> std::io::Result<()> { - use heck::{ToShoutySnakeCase, ToSnakeCase}; - use prost_types::DescriptorProto; +/// Emit `tags.rs`: a nested module tree mirroring the proto's message +/// hierarchy, with one `pub const : u32 = ;` per field. Reads +/// the original (camelCase) descriptor; const/module names go through +/// shouty/snake transforms that yield the same output regardless of the +/// camelCase->snake_case rename, so the consts match the generated Rust API. +fn generate_tags(fds: &FileDescriptorSet, out_path: &std::path::Path) -> std::io::Result<()> { + use heck::{ToShoutySnakeCase as _, ToSnakeCase as _}; - /// prost-parity identifier sanitization (mirror of prost-build's - /// `ident::sanitize_identifier` + `to_snake`), so module names always - /// match what prost would generate for the same message. + /// Mirror of prost-build's identifier sanitization so module names always + /// match the message names buffa/prost would generate. fn module_ident(name: &str) -> String { let snake = name.to_snake_case(); match snake.as_str() { - // Strict and reserved keywords across editions: raw identifier. "as" | "break" | "const" | "continue" | "else" | "enum" | "false" | "fn" | "for" | "if" | "impl" | "in" | "let" | "loop" | "match" | "mod" | "move" | "mut" | "pub" | "ref" | "return" | "static" | "struct" | "trait" | "true" | "type" | "unsafe" | "use" | "where" | "while" | "dyn" | "abstract" | "become" | "box" | "do" | "final" | "macro" | "override" | "priv" | "typeof" | "unsized" | "virtual" | "yield" | "async" | "await" | "try" | "gen" => format!("r#{snake}"), - // Not usable as raw identifiers: underscore suffix. "_" | "super" | "self" | "crate" | "extern" => format!("{snake}_"), - // Digit-leading names get an underscore prefix. other if other.starts_with(|c: char| c.is_numeric()) => format!("_{snake}"), _ => snake, } @@ -181,27 +219,31 @@ fn generate_tags( // Synthetic map-entry messages have no hand-decodable surface. if msg .options - .as_ref() + .as_option() .and_then(|o| o.map_entry) .unwrap_or(false) { return; } + let msg_name = msg.name.as_deref().unwrap_or_default(); let pad = " ".repeat(indent); - out.push_str(&format!("{pad}pub mod {} {{\n", module_ident(msg.name()))); + out.push_str(&format!("{pad}pub mod {} {{\n", module_ident(msg_name))); let mut seen = std::collections::HashSet::new(); for field in &msg.field { - let const_name = field.name().to_shouty_snake_case(); + let const_name = field + .name + .as_deref() + .unwrap_or_default() + .to_shouty_snake_case(); // Two field names collapsing to one const (e.g. fooBar/foo_bar) // would emit duplicate consts; fail loudly at generation time. assert!( seen.insert(const_name.clone()), - "tags.rs: const name collision `{const_name}` in message `{}`", - msg.name() + "tags.rs: const name collision `{const_name}` in message `{msg_name}`" ); out.push_str(&format!( "{pad} pub const {const_name}: u32 = {};\n", - field.number() + field.number.unwrap_or_default() )); } for nested in &msg.nested_type { diff --git a/waproto/src/lib.rs b/waproto/src/lib.rs index 2f0a531d6..f114da891 100644 --- a/waproto/src/lib.rs +++ b/waproto/src/lib.rs @@ -1,15 +1,26 @@ -// Protobuf definitions, generated at build time into OUT_DIR from the -// committed `whatsapp.desc` descriptor (see build.rs). Consumers never need -// `protoc`; editing `whatsapp.proto` requires regenerating the descriptor via -// `scripts/regenerate-proto-desc.sh` and committing both files. +//! Auto-generated protobuf definitions for the WhatsApp wire format. +//! +//! The Rust source (`whatsapp.rs`) is produced by `build.rs` from the +//! pre-compiled descriptor set `whatsapp.desc`, and written to `OUT_DIR` — +//! not tracked in git. To regenerate the descriptor after editing +//! `whatsapp.proto`, run `scripts/regenerate-proto-desc.sh` (wraps `protoc`). #![allow(clippy::large_enum_variant)] pub mod whatsapp { - include!(concat!(env!("OUT_DIR"), "/whatsapp.rs")); + #![allow( + non_camel_case_types, + non_snake_case, + unreachable_patterns, + clippy::derivable_impls, + clippy::match_single_binding, + clippy::needless_else + )] + #[rustfmt::skip] + buffa::include_proto!("whatsapp"); } /// Wire tags of every message field in `whatsapp.proto`, generated alongside -/// the prost code. Hand-written partial decoders must reference these consts +/// the buffa code. Hand-written partial decoders must reference these consts /// (or compile-time assert against them) instead of magic numbers, so schema /// changes surface as compile errors rather than silent wire-format drift. pub mod tags { @@ -18,31 +29,30 @@ pub mod tags { /// Pinned, non-generic codec entry points for the hottest protobuf roots. /// -/// prost's `Message` methods are generic, so rustc instantiates them in every -/// crate that calls them; the per-crate copies carry distinct -/// instantiating-crate symbol hashes that LTO cannot merge, and each calling -/// crate ends up shipping its own copy of the full encode or decode tree -/// (`whatsapp::Message::encode_raw` alone is ~160 KiB per copy). Routing -/// calls through these functions pins a single instantiation in this crate; -/// `#[inline(never)]` keeps MIR inlining from re-expanding them at call -/// sites, which would silently reintroduce the per-crate copies. +/// buffa's `Message` encode/decode methods are generic over the buffer type, so +/// rustc instantiates them in every crate that calls them; the per-crate copies +/// carry distinct instantiating-crate symbol hashes that LTO cannot merge, and +/// each calling crate ends up shipping its own copy of the full encode/decode +/// tree. Routing calls through these functions pins a single instantiation in +/// this crate; `#[inline(never)]` keeps MIR inlining from re-expanding them at +/// call sites, which would silently reintroduce the per-crate copies. /// -/// Decode helpers take `&[u8]` and decode via `&mut &[u8]`, the buffer shape -/// the rest of the workspace already instantiates, so no second buffer-type -/// tree exists. +/// Decode helpers take `&[u8]` and decode via `decode_from_slice`, the buffer +/// shape the rest of the workspace already instantiates, so no second +/// buffer-type tree exists. pub mod codec { use crate::whatsapp; - use prost::Message as _; + use buffa::Message as _; #[inline(never)] pub fn message_encoded_len(msg: &whatsapp::Message) -> usize { - msg.encoded_len() + msg.encoded_len() as usize } /// Append the encoded message to `out`. Infallible into a `Vec`. #[inline(never)] pub fn message_encode_into(msg: &whatsapp::Message, out: &mut Vec) { - msg.encode(out).expect("encode into Vec is infallible"); + msg.encode(out); } #[inline(never)] @@ -50,34 +60,66 @@ pub mod codec { msg.encode_to_vec() } + /// Two-pass encode with a caller-owned `SizeCache`: `compute_size` fills the + /// cache, `write_to` reuses it. The send path needs the size before writing + /// (to pre-size buffers and splice nested fields by hand), so it drives the + /// two passes itself instead of calling `encode`. Pinning both keeps the + /// `Message` encode tree out of the calling crate. #[inline(never)] - pub fn message_decode(mut bytes: &[u8]) -> Result { - whatsapp::Message::decode(&mut bytes) + pub fn message_compute_size(msg: &whatsapp::Message, cache: &mut buffa::SizeCache) -> usize { + msg.compute_size(cache) as usize + } + + #[inline(never)] + pub fn message_write_to( + msg: &whatsapp::Message, + cache: &mut buffa::SizeCache, + out: &mut Vec, + ) { + msg.write_to(cache, out); + } + + #[inline(never)] + pub fn message_decode(bytes: &[u8]) -> Result { + whatsapp::Message::decode_from_slice(bytes) } #[inline(never)] pub fn web_message_info_decode( - mut bytes: &[u8], - ) -> Result { - whatsapp::WebMessageInfo::decode(&mut bytes) + bytes: &[u8], + ) -> Result { + whatsapp::WebMessageInfo::decode_from_slice(bytes) + } + + #[inline(never)] + pub fn history_sync_decode(bytes: &[u8]) -> Result { + whatsapp::HistorySync::decode_from_slice(bytes) + } + + /// History-sync streaming decodes individual `HistorySyncMsg`/`Conversation` + /// records; pinning them here keeps their nested `WebMessageInfo`/`Message` + /// decode tree from being re-instantiated in the calling crate. + #[inline(never)] + pub fn history_sync_msg_decode( + bytes: &[u8], + ) -> Result { + whatsapp::HistorySyncMsg::decode_from_slice(bytes) } #[inline(never)] - pub fn history_sync_decode( - mut bytes: &[u8], - ) -> Result { - whatsapp::HistorySync::decode(&mut bytes) + pub fn conversation_decode(bytes: &[u8]) -> Result { + whatsapp::Conversation::decode_from_slice(bytes) } #[inline(never)] pub fn message_context_info_encoded_len(mci: &whatsapp::MessageContextInfo) -> usize { - mci.encoded_len() + mci.encoded_len() as usize } /// Append the encoded `MessageContextInfo` to `out`. Infallible into a `Vec`. #[inline(never)] pub fn message_context_info_encode_into(mci: &whatsapp::MessageContextInfo, out: &mut Vec) { - mci.encode(out).expect("encode into Vec is infallible"); + mci.encode(out); } #[inline(never)] @@ -85,19 +127,85 @@ pub mod codec { mci.encode_to_vec() } - /// Merge wire bytes into an existing `MessageContextInfo` (prost merge + /// `SizeCache`-driven two-pass encode for `MessageContextInfo`, mirroring + /// [`message_compute_size`]/[`message_write_to`]; the send path splices the + /// mci as a nested length-delimited field, so it needs the size before the + /// write. + #[inline(never)] + pub fn message_context_info_compute_size( + mci: &whatsapp::MessageContextInfo, + cache: &mut buffa::SizeCache, + ) -> usize { + mci.compute_size(cache) as usize + } + + #[inline(never)] + pub fn message_context_info_write_to( + mci: &whatsapp::MessageContextInfo, + cache: &mut buffa::SizeCache, + out: &mut Vec, + ) { + mci.write_to(cache, out); + } + + /// Merge wire bytes into an existing `MessageContextInfo` (proto merge /// semantics: later-set fields win). - /// - /// Merges through a `&mut &mut &[u8]` buffer — the exact shape - /// `Message::decode` threads into its nested `MessageContextInfo` — so the - /// `BotMetadata` decode subtree reuses that instantiation instead of - /// emitting a second ~67 KiB copy in a distinct buffer-type shape. #[inline(never)] pub fn message_context_info_merge( mci: &mut whatsapp::MessageContextInfo, bytes: &[u8], - ) -> Result<(), prost::DecodeError> { - let mut cursor = bytes; - mci.merge(&mut &mut cursor) + ) -> Result<(), buffa::DecodeError> { + mci.merge_from_slice(bytes) + } +} + +#[cfg(test)] +mod tests { + use super::whatsapp as wa; + use buffa::Message; + use buffa::view::MessageView; + + #[test] + fn generated_views_and_oneofs_round_trip() { + let msg = wa::Message { + interactive_message: buffa::MessageField::some(wa::message::InteractiveMessage { + interactive_message: Some( + wa::message::interactive_message::InteractiveMessage::NativeFlowMessage( + Box::new(wa::message::interactive_message::NativeFlowMessage { + buttons: vec![ + wa::message::interactive_message::native_flow_message::NativeFlowButton { + name: Some("quick_reply".to_string()), + ..Default::default() + }, + ], + message_version: Some(1), + ..Default::default() + }), + ), + ), + ..Default::default() + }), + ..Default::default() + }; + + let bytes = msg.encode_to_vec(); + let decoded = wa::Message::decode_from_slice(&bytes).unwrap(); + let interactive = decoded.interactive_message.as_option().unwrap(); + let Some(wa::message::interactive_message::InteractiveMessage::NativeFlowMessage(native)) = + interactive.interactive_message.as_ref() + else { + panic!("expected native flow oneof"); + }; + assert_eq!(native.buttons[0].name.as_deref(), Some("quick_reply")); + + let view = wa::MessageView::decode_view(&bytes).unwrap(); + let interactive = view.interactive_message.as_option().unwrap(); + let Some(wa::message::interactive_message::InteractiveMessageView::NativeFlowMessage( + native, + )) = interactive.interactive_message.as_ref() + else { + panic!("expected native flow view oneof"); + }; + assert_eq!(native.buttons[0].name, Some("quick_reply")); } } diff --git a/waproto/tests/serde_enum_repr.rs b/waproto/tests/serde_enum_repr.rs new file mode 100644 index 000000000..8dc67ce1f --- /dev/null +++ b/waproto/tests/serde_enum_repr.rs @@ -0,0 +1,23 @@ +//! Enum serde representation: numeric repr (prost parity / JS bridge) under the +//! `serde-enum-repr` feature, variant name otherwise. + +use waproto::whatsapp::ADVEncryptionType; + +#[test] +fn enum_serde_representation() { + let json = serde_json::to_value(ADVEncryptionType::HOSTED).unwrap(); + + #[cfg(feature = "serde-enum-repr")] + assert_eq!( + json, + serde_json::json!(1), + "enum should serialize as its numeric repr under serde-enum-repr, got {json}" + ); + + #[cfg(not(feature = "serde-enum-repr"))] + assert_eq!( + json, + serde_json::json!("HOSTED"), + "enum should serialize as its variant name by default, got {json}" + ); +} diff --git a/waproto/tests/serde_enum_snake_case.rs b/waproto/tests/serde_enum_snake_case.rs new file mode 100644 index 000000000..4e91ec939 --- /dev/null +++ b/waproto/tests/serde_enum_snake_case.rs @@ -0,0 +1,18 @@ +//! Under `serde-snake-case`, enums deserialize from the lowercased proto name +//! (CHROME -> "chrome", ANDROID_TABLET -> "android_tablet"). buffa emits +//! SCREAMING_SNAKE variants, so the rename rule must be `lowercase`, not +//! `snake_case` (which would expect "c_h_r_o_m_e" / "a_n_d_r_o_i_d__t_a_b_l_e_t"). + +#![cfg(all(feature = "serde-snake-case", not(feature = "serde-enum-repr")))] + +use waproto::whatsapp::device_props::PlatformType; + +#[test] +fn enum_deserializes_from_lowercased_proto_name() { + let chrome: PlatformType = serde_json::from_value(serde_json::json!("chrome")).unwrap(); + assert_eq!(chrome, PlatformType::CHROME); + + // Multi-word: only `lowercase` yields the single-underscore form. + let tablet: PlatformType = serde_json::from_value(serde_json::json!("android_tablet")).unwrap(); + assert_eq!(tablet, PlatformType::ANDROID_TABLET); +}