Skip to content

feat: Implement bidirectional ProtocolNode types for prekey responses - #250

Merged
jlucaso1 merged 5 commits into
mainfrom
feat-improve-iq-stanza
Jan 26, 2026
Merged

feat: Implement bidirectional ProtocolNode types for prekey responses#250
jlucaso1 merged 5 commits into
mainfrom
feat-improve-iq-stanza

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jan 25, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

  • New Features

    • Type-safe message stanza with JID-aware attributes.
    • Extended media-connection response with richer host/metadata support.
    • New protocol representations for prekeys and prekey bundles.
    • Sampling props added alongside A/B experiment props.
  • Improvements

    • Tighter validation for message attributes (JID-typed).
    • Node-based prekey parsing and bundle construction.
    • Enhanced derive codegen for JID/String attributes with optional/default handling.
    • Reworked stream-error handling with clearer conflict and code paths.
  • Tests

    • Added comprehensive round-trip and validation tests for new protocol types.

✏️ Tip: You can customize this high-level summary in your review settings.

- Added SignedPreKeyNode and OneTimePreKeyNode structs for handling signed and one-time prekeys.
- Introduced PreKeyBundleUserNode to represent complete prekey bundle responses.
- Implemented ProtocolNode trait for these types to facilitate XML serialization and deserialization.
- Added helper functions for byte manipulation related to prekey IDs.
- Enhanced tests for new prekey structures and their round-trip serialization.

feat: Extend A/B property handling in props module

- Introduced SamplingProp struct for A/B sampling properties.
- Updated AbPropConfig enum to include both AbProp and SamplingProp.
- Modified PropsResponse to handle a vector of AbPropConfig instead of just AbProp.
- Enhanced parsing logic in PropsSpec to utilize the new ProtocolNode implementations.
- Added tests for new A/B property structures and their serialization.

feat: Add message stanza types with ProtocolNode derive macro

- Created MessageStanza struct for typed message stanzas with JID-aware attributes.
- Implemented ProtocolNode derive macro for MessageStanza to facilitate XML serialization.
- Updated stanza module to include message handling.

fix: Update lib.rs to use self as wacore
@coderabbitai

coderabbitai Bot commented Jan 25, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds JID-aware ProtocolNode derive behavior, new ProtocolNode types for prekeys and media connections, a type-safe MessageStanza, and switches prekey retry handling to node-based representations across the codebase.

Changes

Cohort / File(s) Summary
ProtocolNode derive
wacore/derive/src/lib.rs
Add per-field AttrType (String/Jid) and optionality; generate conditional into_node/from_node code, update Default generation and attr parsing.
Prekey protocol nodes
wacore/src/iq/prekeys.rs
Add OneTimePreKeyNode, SignedPreKeyNode, PreKeyBundleUserNode with ProtocolNode impls, 3-byte id helpers, from_bundle constructor, and tests.
Prekey retry integration
src/retry.rs
Replace manual id/value parsing with node-based OneTimePreKeyNode/SignedPreKeyNode extraction and construct prekey/signed-prekey children via node builders; update PreKeyBundle assembly and send/receive retry paths.
Message parsing
src/handlers/message.rs
Tighten "from" attribute handling to use Option<Jid> directly, removing empty-string check and relying on JID-typed attr presence.
Media connection extensions
wacore/src/iq/mediaconn.rs
Add MediaConnHostExtended and MediaConnResponseExtended with ProtocolNode impls, nested host/download structures, helpers, and round-trip tests.
A/B props and sampling
wacore/src/iq/props.rs
Introduce SamplingProp and AbPropConfig enum; change PropsResponse.props to Vec<AbPropConfig>; add ProtocolNode impls, validations, and tests.
Message stanza type
wacore/src/stanza/message.rs, wacore/src/stanza/mod.rs
Add MessageStanza (JID-typed fields, derives ProtocolNode) and re-export it from stanza module.
Crate alias
wacore/src/lib.rs
Add extern crate self as wacore; at crate root.
Client stream handling
src/client.rs
Rework handle_stream_error flow: separate conflict-type path vs. code-specific paths (515/516/503/unknown); add guards in send_ack_for.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I nibble nodes and bind each JID,

Prekeys hop neat, no bytes to skid,
Hosts stack up in tidy rows,
Messages wear proper JID clothes,
A cheerful rabbit tends the grid.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Title check ⚠️ Warning The PR title claims to implement bidirectional ProtocolNode types for prekey responses, but the changeset encompasses significantly broader scope including message stanza types, props handling, media connection structures, retry logic refactoring, and derive macro enhancements. Revise the title to reflect the comprehensive scope, such as: 'feat: Add ProtocolNode support for prekeys, props, media connections, and message stanzas' or consider splitting into multiple focused PRs.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Jan 25, 2026

Copy link
Copy Markdown

🐰 Bencher Report

Branchfeat-improve-iq-stanza
Testbedubuntu-latest

🚨 1 Alert

BenchmarkMeasure
Units
ViewBenchmark Result
(Result Δ%)
Upper Boundary
(Limit %)
binary_benchmark::unpack_group::bench_unpack_compressedInstructions
instructions x 1e3
📈 plot
🚷 threshold
🚨 alert (🔔)
556.53 x 1e3
(+10.94%)Baseline: 501.66 x 1e3
526.74 x 1e3
(105.66%)

Click to view all benchmark results
BenchmarkInstructionsBenchmark Result
instructions
(Result Δ%)
Upper Boundary
instructions
(Limit %)
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled()📈 view plot
🚷 view threshold
7,043.00
(-3.71%)Baseline: 7,314.62
7,680.35
(91.70%)
binary_benchmark::child_iteration_group::bench_get_children_by_tag📈 view plot
🚷 view threshold
857,938.00
(+2.41%)Baseline: 837,725.20
879,611.46
(97.54%)
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled()📈 view plot
🚷 view threshold
24,919.00
(0.00%)Baseline: 24,919.00
26,164.95
(95.24%)
binary_benchmark::marshal_group::bench_marshal_allocating📈 view plot
🚷 view threshold
127,918.00
(-8.41%)Baseline: 139,664.79
146,648.03
(87.23%)
binary_benchmark::marshal_group::bench_marshal_long_string📈 view plot
🚷 view threshold
19,586.00
(+0.64%)Baseline: 19,461.12
20,434.18
(95.85%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer📈 view plot
🚷 view threshold
128,018.00
(-8.41%)Baseline: 139,765.53
146,753.81
(87.23%)
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled()📈 view plot
🚷 view threshold
106,270.00
(-0.00%)Baseline: 106,274.67
111,588.40
(95.23%)
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled()📈 view plot
🚷 view threshold
8,487.00
(0.00%)Baseline: 8,487.00
8,911.35
(95.24%)
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled()📈 view plot
🚷 view threshold
49,326.00
(-0.35%)Baseline: 49,496.98
51,971.83
(94.91%)
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled()📈 view plot
🚷 view threshold
3,025.00
(-4.83%)Baseline: 3,178.44
3,337.36
(90.64%)
binary_benchmark::unpack_group::bench_unpack_compressed📈 view plot
🚷 view threshold
🚨 view alert (🔔)
556,531.00
(+10.94%)Baseline: 501,656.74
526,739.58
(105.66%)

binary_benchmark::unpack_group::bench_unpack_uncompressed📈 view plot
🚷 view threshold
779.00
(+0.01%)Baseline: 778.89
817.84
(95.25%)
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data()📈 view plot
🚷 view threshold
27,888,217.00
(+0.41%)Baseline: 27,774,425.14
29,163,146.40
(95.63%)
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message()📈 view plot
🚷 view threshold
5,571,063.00
(+0.01%)Baseline: 5,570,257.57
5,848,770.45
(95.25%)
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session()📈 view plot
🚷 view threshold
178,206.00
(+0.21%)Baseline: 177,828.31
186,719.73
(95.44%)
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session()📈 view plot
🚷 view threshold
179,022.00
(+0.21%)Baseline: 178,638.89
187,570.83
(95.44%)
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users()📈 view plot
🚷 view threshold
17,452,146.00
(+1.02%)Baseline: 17,276,404.63
18,140,224.86
(96.21%)
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender()📈 view plot
🚷 view threshold
295,925.00
(+0.07%)Baseline: 295,731.69
310,518.27
(95.30%)
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message()📈 view plot
🚷 view threshold
12,597,159.00
(+0.03%)Baseline: 12,593,274.86
13,222,938.60
(95.27%)
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution()📈 view plot
🚷 view threshold
715,862.00
(-0.01%)Baseline: 715,967.09
751,765.44
(95.22%)
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions()📈 view plot
🚷 view threshold
41,798.00
(+0.14%)Baseline: 41,741.50
43,828.57
(95.37%)
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction()📈 view plot
🚷 view threshold
15,561,046.00
(0.00%)Baseline: 15,561,046.00
16,339,098.30
(95.24%)
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages()📈 view plot
🚷 view threshold
5,531,221.00
(-0.06%)Baseline: 5,534,521.94
5,811,248.03
(95.18%)
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session()📈 view plot
🚷 view threshold
965,435.00
(-0.19%)Baseline: 967,247.69
1,015,610.07
(95.06%)
libsignal_benchmark::signature_group::bench_key_generation keygen📈 view plot
🚷 view threshold
2,823,499.00
(+0.01%)Baseline: 2,823,296.29
2,964,461.11
(95.24%)
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message()📈 view plot
🚷 view threshold
3,445,806.00
(-8.02%)Baseline: 3,746,108.35
3,933,413.77
(87.60%)
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message()📈 view plot
🚷 view threshold
122,683,374.00
(-1.86%)Baseline: 125,004,650.94
131,254,883.49
(93.47%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message()📈 view plot
🚷 view threshold
11,815.00
(+0.03%)Baseline: 11,811.95
12,402.55
(95.26%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message()📈 view plot
🚷 view threshold
3,837.00
(+0.14%)Baseline: 3,831.53
4,023.11
(95.37%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended()📈 view plot
🚷 view threshold
88,029.00
(+0.02%)Baseline: 88,010.77
92,411.31
(95.26%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple()📈 view plot
🚷 view threshold
80,074.00
(+0.03%)Baseline: 80,053.35
84,056.02
(95.26%)
reporting_token_benchmark::key_derivation_group::bench_key_derivation📈 view plot
🚷 view threshold
51,035.00
(0.00%)Baseline: 51,035.00
53,586.75
(95.24%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message()📈 view plot
🚷 view threshold
5,734.00
(+0.05%)Baseline: 5,730.95
6,017.50
(95.29%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message()📈 view plot
🚷 view threshold
2,121.00
(+0.26%)Baseline: 2,115.53
2,221.31
(95.48%)
reporting_token_benchmark::token_calculation_group::bench_token_calculation📈 view plot
🚷 view threshold
21,921.00
(+0.06%)Baseline: 21,907.82
23,003.21
(95.30%)
🐰 View full continuous benchmarking report in Bencher

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/retry.rs (1)

569-575: Use device_snapshot.signed_pre_key_id instead of hardcoding 1.

The signed prekey ID is already stored in device_snapshot (inherited from the Device struct). Hardcoding it to 1 will cause a mismatch with the actual signed prekey ID that was generated and stored for this device, leading the recipient to cache or reference the wrong key. Pull the value from the snapshot like you do for other device attributes:

let skey_id = device_snapshot.signed_pre_key_id;
🤖 Fix all issues with AI agents
In `@src/retry.rs`:
- Around line 424-431: The code silently drops malformed <key> entries by
converting Results to Options with .ok(); change the logic so that if
keys_node.get_optional_child("key") returns Some(key_node) then call
OneTimePreKeyNode::try_from_node(key_node) and propagate its Err, and likewise
propagate errors from PublicKey::from_djb_public_key_bytes instead of using
.ok()/ .ok()?; update the surrounding function signature to return Result so
prekey_data becomes a Result<Option<(u64, PublicKey)>, YourErrorType>, and
return Err when try_from_node or from_djb_public_key_bytes fail (refer to
symbols keys_node, OneTimePreKeyNode::try_from_node,
PublicKey::from_djb_public_key_bytes, and prekey_data).

In `@wacore/src/iq/prekeys.rs`:
- Around line 346-381: In try_from_node ensure the parsed signed-prekey's value
and signature are validated for expected lengths before returning: after
extracting public_bytes from value_node and signature from signature node (in
try_from_node) add explicit checks (e.g., public_bytes.len() == 32 and
signature.len() == 64 or whatever the protocol requires) and return an anyhow!
error if they don't match; apply the same pattern to the one-time prekey parsing
code paths (the counterpart functions handling <prekey> nodes) so malformed
nodes fail fast and downstream code can safely assume 32/64-byte sizes.
- Around line 597-623: The optional-node parsing silently swallows malformed
`<key>` and `<device-identity>` nodes and the `identity_key` isn't validated for
length; change parsing so that if node.get_optional_child("key") or
node.get_optional_child("device-identity") returns Some(n) you attempt strict
parsing and propagate any error (i.e. call OneTimePreKeyNode::try_from_node(n)
and return Err on failure rather than .ok()), and for `device-identity` if
present require NodeContent::Bytes else return an error instead of None; also
validate `identity_key` length equals 32 bytes after extracting it (return an
error via anyhow! if not 32). Ensure you update the same block that uses
required_child/node.get_optional_child, identity_key,
SignedPreKeyNode::try_from_node, OneTimePreKeyNode::try_from_node and the
device-identity match to follow this strict behavior.
🧹 Nitpick comments (4)
wacore/src/iq/mediaconn.rs (1)

67-89: Remove unnecessary .clone() on hostname.

The hostname parameter is already an owned String and is only used once, so the clone is redundant.

♻️ Suggested fix
     pub fn primary(
         hostname: String,
         fallback_hostname: String,
         ip4: String,
         ip6: String,
         download_categories: Vec<String>,
         download_buckets: Vec<String>,
     ) -> Self {
         Self {
-            hostname: hostname.clone(),
+            hostname,
             host_type: "primary".to_string(),
             fallback_hostname: Some(fallback_hostname),
             ip4: Some(ip4.clone()),
             ip6: Some(ip6.clone()),
             fallback_ip4: Some(ip4),
             fallback_ip6: Some(ip6),
             upload: true,
             download: true,
             download_categories,
             download_buckets,
         }
     }
wacore/derive/src/lib.rs (2)

215-222: Consider making the catch-all arm a compile-time error.

The _ => quote! {} arm silently produces no output, which could lead to confusing compilation errors downstream if this branch is ever reached due to a logic bug. Converting it to a panic! or unreachable! would provide clearer diagnostics during macro expansion.

♻️ Suggested fix
                     (AttrType::String, false, Some(default)) => {
                         quote! { `#field_ident`: `#default.to_string`() }
                     }
-                    _ => quote! {}, // This shouldn't happen if all_have_defaults is true
+                    _ => unreachable!("all_have_defaults check should prevent this branch"),
                 }

187-194: Clarify behavior: optional string with default never returns None.

When both optional and default are specified, the generated code always produces Some(...), never None. If this is intentional (meaning "optional in XML but always has a value in Rust"), consider documenting this in the macro's doc comment. Otherwise, users might expect None to be possible.

wacore/src/iq/prekeys.rs (1)

289-300: Guard against out-of-range prekey IDs before truncation.

truncate_to_3bytes silently drops the high byte; if a caller ever passes > 0x00FF_FFFF, the serialized node won’t match the in-memory id. Consider a lightweight guard to catch misuse early.

🔧 Suggested guard
 fn truncate_to_3bytes(id: u32) -> Vec<u8> {
+    debug_assert!(
+        id <= 0x00FF_FFFF,
+        "prekey id exceeds 3-byte range: {id}"
+    );
     id.to_be_bytes()[1..].to_vec()
 }

Comment thread src/retry.rs Outdated
Comment thread wacore/src/iq/prekeys.rs
Comment thread wacore/src/iq/prekeys.rs Outdated
@jlucaso1
jlucaso1 force-pushed the feat-improve-iq-stanza branch from af3c352 to 2a52187 Compare January 25, 2026 23:24

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/client.rs`:
- Around line 727-729: The current check using is_connected() (which uses
try_lock) can falsely return false under transient contention and drop ACKs;
change this to use an async-aware lock check instead of try_lock — either make
is_connected async and await the lock or directly acquire the async lock (e.g.,
await the same connection/state mutex/read-write lock used elsewhere) before
checking connectivity and expected_disconnect, so the ACK path isn't skipped due
to brief contention; update the call site where is_connected() is used and
ensure expected_disconnect.load(Ordering::Relaxed) is still evaluated safely
after acquiring the async lock (references: is_connected(),
expected_disconnect).
🧹 Nitpick comments (2)
wacore/derive/src/lib.rs (1)

368-409: Add explicit validation for optional/default attribute combos.

Right now #[attr(optional)] on a non-Option<T> field or default on a jid attribute will fail with generated-code type errors. Consider catching these in extract_attr_info and returning a targeted syn::Error to make misuse clearer.

♻️ Possible validation guard
             match attr_name {
                 Some(name) => {
                     let attr_type = if is_jid {
                         AttrType::Jid
                     } else {
                         AttrType::String
                     };

                     // Determine if optional: either explicit marker or Option<T> type
                     let optional = explicit_optional || is_optional;

+                    if explicit_optional && !is_optional {
+                        return Err(syn::Error::new_spanned(
+                            &field.ty,
+                            "`optional` requires an Option<T> field type",
+                        ));
+                    }
+                    if is_jid && default.is_some() {
+                        return Err(syn::Error::new_spanned(
+                            attr,
+                            "`default` is only supported for String attributes",
+                        ));
+                    }
+
                     return Ok(Some(AttrFieldInfo {
                         field_ident,
                         attr_name: name,
                         attr_type,
                         optional,
                         default,
                     }));
wacore/src/iq/mediaconn.rs (1)

162-197: Use required_attr for required attributes.

hostname and auth are mandatory; using the helper keeps error messages consistent and aligns with parsing patterns elsewhere in wacore/src/iq. As per coding guidelines, prefer the shared node helpers for required fields.

🔧 Suggested adjustment
+use crate::iq::node::required_attr;
-        let hostname = attrs
-            .optional_string("hostname")
-            .ok_or_else(|| anyhow!("missing hostname attribute"))?
-            .to_string();
+        let hostname = required_attr(node, "hostname")?.to_string();
-        let auth = attrs
-            .optional_string("auth")
-            .ok_or_else(|| anyhow!("missing auth attribute"))?
-            .to_string();
+        let auth = required_attr(node, "auth")?.to_string();

Also applies to: 266-275

Comment thread src/client.rs
Comment on lines +727 to +729
if !self.is_connected() || self.expected_disconnect.load(Ordering::Relaxed) {
return Ok(());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Avoid dropping ACKs on transient lock contention.

Line 727 uses is_connected(), which relies on try_lock; a briefly held lock will return false and skip the ACK entirely. Prefer an async lock check here so ACKs aren’t silently dropped under load.

🛠️ Suggested fix
-        if !self.is_connected() || self.expected_disconnect.load(Ordering::Relaxed) {
-            return Ok(());
-        }
+        if self.expected_disconnect.load(Ordering::Relaxed) {
+            return Ok(());
+        }
+        if self.noise_socket.lock().await.is_none() {
+            return Ok(());
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if !self.is_connected() || self.expected_disconnect.load(Ordering::Relaxed) {
return Ok(());
}
if self.expected_disconnect.load(Ordering::Relaxed) {
return Ok(());
}
if self.noise_socket.lock().await.is_none() {
return Ok(());
}
🤖 Prompt for AI Agents
In `@src/client.rs` around lines 727 - 729, The current check using is_connected()
(which uses try_lock) can falsely return false under transient contention and
drop ACKs; change this to use an async-aware lock check instead of try_lock —
either make is_connected async and await the lock or directly acquire the async
lock (e.g., await the same connection/state mutex/read-write lock used
elsewhere) before checking connectivity and expected_disconnect, so the ACK path
isn't skipped due to brief contention; update the call site where is_connected()
is used and ensure expected_disconnect.load(Ordering::Relaxed) is still
evaluated safely after acquiring the async lock (references: is_connected(),
expected_disconnect).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@wacore/src/iq/prekeys.rs`:
- Around line 289-293: The helper truncate_to_3bytes currently uses
debug_assert!, which is stripped in release builds and allows silent truncation
of out-of-range prekey ids; change the guard to a hard runtime check (e.g.,
replace debug_assert! with assert! or an explicit if that panics with a clear
message) to ensure id > 0x00FF_FFFF is always rejected, and keep the existing
id.to_be_bytes()[1..].to_vec() logic unchanged; reference function:
truncate_to_3bytes and the prekey id check.
- Around line 591-623: The code accepts signed prekeys without validating the
<type> child; update the parsing to require and validate the <type> element
(e.g., via required_child(node, "type")?) and ensure its content matches the
expected curve string before calling SignedPreKeyNode::try_from_node; if the
<type> child is missing or the value is not the supported curve, return an error
(use the same anyhow! pattern). You can either add this check in the current
parsing function (before calling SignedPreKeyNode::try_from_node) or add the
validation inside SignedPreKeyNode::try_from_node itself so mismatched/absent
types are rejected consistently.

In `@wacore/src/iq/props.rs`:
- Around line 164-189: The try_from_node implementation for AbPropConfig
currently moves the `experiment` Result when matching, then reuses it for error
reporting; change the flow to a nested match so you pattern-match `experiment`
and, on Err(e1), attempt `sampling` and pattern-match it too, returning
Ok(Self::Experiment(prop)) or Ok(Self::Sampling(prop)) on success and
constructing the final anyhow::Error using the two captured errors (e1 and e2)
on double failure; reference AbProp::try_from_node, SamplingProp::try_from_node
and the try_from_node function for AbPropConfig to locate where to replace the
if-let checks with a match-based approach that avoids moving values before you
need their errors.
🧹 Nitpick comments (1)
wacore/derive/src/lib.rs (1)

330-340: Validate optional/default combos early to avoid opaque macro failures.
#[attr(..., optional)] on non‑Option<T> (or default on jid attrs) will generate confusing compile errors or hit the unreachable! branch later. Consider rejecting these during parsing with a clear diagnostic.

🧩 Suggested validation in extract_attr_info
             attr.parse_nested_meta(|meta| {
                 if meta.path.is_ident("name") {
                     let value: syn::LitStr = meta.value()?.parse()?;
                     attr_name = Some(value.value());
                 } else if meta.path.is_ident("default") {
                     let value: syn::LitStr = meta.value()?.parse()?;
                     default = Some(value.value());
                 } else if meta.path.is_ident("jid") {
                     is_jid = true;
                 } else if meta.path.is_ident("optional") {
                     explicit_optional = true;
                 }
                 Ok(())
             })?;

+            if explicit_optional && !is_optional {
+                return Err(syn::Error::new_spanned(
+                    &field.ty,
+                    "`optional` requires an Option<T> field",
+                ));
+            }
+            if is_jid && default.is_some() {
+                return Err(syn::Error::new_spanned(
+                    attr,
+                    "`default` is only supported for String attributes",
+                ));
+            }

Also applies to: 368-409, 424-432

Comment thread wacore/src/iq/prekeys.rs
Comment on lines +289 to +293
/// Helper function to truncate u32 to 3-byte big-endian representation.
fn truncate_to_3bytes(id: u32) -> Vec<u8> {
debug_assert!(id <= 0x00FF_FFFF, "prekey id exceeds 3-byte range: {id}");
id.to_be_bytes()[1..].to_vec()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid silent truncation of prekey IDs in release builds.
debug_assert! is stripped in release, so out‑of‑range IDs will truncate silently, corrupting key IDs. Prefer a hard check (or validation in constructors) that always runs.

🔧 Safer guard
 fn truncate_to_3bytes(id: u32) -> Vec<u8> {
-    debug_assert!(id <= 0x00FF_FFFF, "prekey id exceeds 3-byte range: {id}");
+    assert!(id <= 0x00FF_FFFF, "prekey id exceeds 3-byte range: {id}");
     id.to_be_bytes()[1..].to_vec()
 }
🤖 Prompt for AI Agents
In `@wacore/src/iq/prekeys.rs` around lines 289 - 293, The helper
truncate_to_3bytes currently uses debug_assert!, which is stripped in release
builds and allows silent truncation of out-of-range prekey ids; change the guard
to a hard runtime check (e.g., replace debug_assert! with assert! or an explicit
if that panics with a clear message) to ensure id > 0x00FF_FFFF is always
rejected, and keep the existing id.to_be_bytes()[1..].to_vec() logic unchanged;
reference function: truncate_to_3bytes and the prekey id check.

Comment thread wacore/src/iq/prekeys.rs
Comment on lines +591 to +623
// Parse registration ID (4 bytes big-endian)
let reg_node = required_child(node, "registration")?;
let reg_bytes = reg_node
.content
.as_ref()
.and_then(|c| match c {
NodeContent::Bytes(b) => Some(b),
_ => None,
})
.ok_or_else(|| anyhow!("missing bytes in <registration>"))?;
if reg_bytes.len() != 4 {
return Err(anyhow!("registration ID must be 4 bytes"));
}
let registration_id =
u32::from_be_bytes([reg_bytes[0], reg_bytes[1], reg_bytes[2], reg_bytes[3]]);

// Parse identity key (32 bytes)
let identity_node = required_child(node, "identity")?;
let identity_key = identity_node
.content
.as_ref()
.and_then(|c| match c {
NodeContent::Bytes(b) => Some(b.clone()),
_ => None,
})
.ok_or_else(|| anyhow!("missing bytes in <identity>"))?;
if identity_key.len() != 32 {
return Err(anyhow!("identity key must be 32 bytes"));
}

// Parse signed prekey
let skey_node = required_child(node, "skey")?;
let signed_pre_key = SignedPreKeyNode::try_from_node(skey_node)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate the <type> child to enforce the expected curve.
try_from_node ignores <type> entirely; a mismatched or missing type should error to avoid accepting unsupported key formats.

🧩 Proposed validation
         // Parse registration ID (4 bytes big-endian)
         let reg_node = required_child(node, "registration")?;
         let reg_bytes = reg_node
             .content
             .as_ref()
             .and_then(|c| match c {
                 NodeContent::Bytes(b) => Some(b),
                 _ => None,
             })
             .ok_or_else(|| anyhow!("missing bytes in <registration>"))?;
         if reg_bytes.len() != 4 {
             return Err(anyhow!("registration ID must be 4 bytes"));
         }
         let registration_id =
             u32::from_be_bytes([reg_bytes[0], reg_bytes[1], reg_bytes[2], reg_bytes[3]]);

+        // Parse type (expect Curve25519 = 0x05)
+        let type_node = required_child(node, "type")?;
+        let type_bytes = type_node
+            .content
+            .as_ref()
+            .and_then(|c| match c {
+                NodeContent::Bytes(b) => Some(b),
+                _ => None,
+            })
+            .ok_or_else(|| anyhow!("missing bytes in <type>"))?;
+        if type_bytes != [5] {
+            return Err(anyhow!("unexpected key type in <type>"));
+        }
🤖 Prompt for AI Agents
In `@wacore/src/iq/prekeys.rs` around lines 591 - 623, The code accepts signed
prekeys without validating the <type> child; update the parsing to require and
validate the <type> element (e.g., via required_child(node, "type")?) and ensure
its content matches the expected curve string before calling
SignedPreKeyNode::try_from_node; if the <type> child is missing or the value is
not the supported curve, return an error (use the same anyhow! pattern). You can
either add this check in the current parsing function (before calling
SignedPreKeyNode::try_from_node) or add the validation inside
SignedPreKeyNode::try_from_node itself so mismatched/absent types are rejected
consistently.

Comment thread wacore/src/iq/props.rs
Comment on lines +164 to +189
fn try_from_node(node: &Node) -> Result<Self, anyhow::Error> {
if node.tag != "prop" {
return Err(anyhow::anyhow!("expected <prop>, got <{}>", node.tag));
}

let experiment = AbProp::try_from_node(node);
if let Ok(prop) = experiment {
return Ok(Self::Experiment(prop));
}

let sampling = SamplingProp::try_from_node(node);
if let Ok(prop) = sampling {
return Ok(Self::Sampling(prop));
}

let experiment_err = experiment
.err()
.unwrap_or_else(|| anyhow::anyhow!("unknown error"));
let sampling_err = sampling
.err()
.unwrap_or_else(|| anyhow::anyhow!("unknown error"));
Err(anyhow::anyhow!(
"prop did not match experiment or sampling config: experiment_err={}; sampling_err={}",
experiment_err,
sampling_err
))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Fix move-after-use in AbPropConfig::try_from_node.
if let Ok(prop) = experiment moves experiment, then it’s reused for error reporting—this won’t compile. Use a match to avoid moving the result twice.

✅ Safe match-based fix
-        let experiment = AbProp::try_from_node(node);
-        if let Ok(prop) = experiment {
-            return Ok(Self::Experiment(prop));
-        }
-
-        let sampling = SamplingProp::try_from_node(node);
-        if let Ok(prop) = sampling {
-            return Ok(Self::Sampling(prop));
-        }
-
-        let experiment_err = experiment
-            .err()
-            .unwrap_or_else(|| anyhow::anyhow!("unknown error"));
-        let sampling_err = sampling
-            .err()
-            .unwrap_or_else(|| anyhow::anyhow!("unknown error"));
-        Err(anyhow::anyhow!(
-            "prop did not match experiment or sampling config: experiment_err={}; sampling_err={}",
-            experiment_err,
-            sampling_err
-        ))
+        let experiment = AbProp::try_from_node(node);
+        let sampling = SamplingProp::try_from_node(node);
+        match (experiment, sampling) {
+            (Ok(prop), _) => Ok(Self::Experiment(prop)),
+            (Err(_), Ok(prop)) => Ok(Self::Sampling(prop)),
+            (Err(experiment_err), Err(sampling_err)) => Err(anyhow::anyhow!(
+                "prop did not match experiment or sampling config: experiment_err={}; sampling_err={}",
+                experiment_err,
+                sampling_err
+            )),
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn try_from_node(node: &Node) -> Result<Self, anyhow::Error> {
if node.tag != "prop" {
return Err(anyhow::anyhow!("expected <prop>, got <{}>", node.tag));
}
let experiment = AbProp::try_from_node(node);
if let Ok(prop) = experiment {
return Ok(Self::Experiment(prop));
}
let sampling = SamplingProp::try_from_node(node);
if let Ok(prop) = sampling {
return Ok(Self::Sampling(prop));
}
let experiment_err = experiment
.err()
.unwrap_or_else(|| anyhow::anyhow!("unknown error"));
let sampling_err = sampling
.err()
.unwrap_or_else(|| anyhow::anyhow!("unknown error"));
Err(anyhow::anyhow!(
"prop did not match experiment or sampling config: experiment_err={}; sampling_err={}",
experiment_err,
sampling_err
))
fn try_from_node(node: &Node) -> Result<Self, anyhow::Error> {
if node.tag != "prop" {
return Err(anyhow::anyhow!("expected <prop>, got <{}>", node.tag));
}
let experiment = AbProp::try_from_node(node);
let sampling = SamplingProp::try_from_node(node);
match (experiment, sampling) {
(Ok(prop), _) => Ok(Self::Experiment(prop)),
(Err(_), Ok(prop)) => Ok(Self::Sampling(prop)),
(Err(experiment_err), Err(sampling_err)) => Err(anyhow::anyhow!(
"prop did not match experiment or sampling config: experiment_err={}; sampling_err={}",
experiment_err,
sampling_err
)),
}
}
🤖 Prompt for AI Agents
In `@wacore/src/iq/props.rs` around lines 164 - 189, The try_from_node
implementation for AbPropConfig currently moves the `experiment` Result when
matching, then reuses it for error reporting; change the flow to a nested match
so you pattern-match `experiment` and, on Err(e1), attempt `sampling` and
pattern-match it too, returning Ok(Self::Experiment(prop)) or
Ok(Self::Sampling(prop)) on success and constructing the final anyhow::Error
using the two captured errors (e1 and e2) on double failure; reference
AbProp::try_from_node, SamplingProp::try_from_node and the try_from_node
function for AbPropConfig to locate where to replace the if-let checks with a
match-based approach that avoids moving values before you need their errors.

@jlucaso1
jlucaso1 merged commit a77d753 into main Jan 26, 2026
4 checks passed
@jlucaso1
jlucaso1 deleted the feat-improve-iq-stanza branch January 26, 2026 00:39
jlucaso1 added a commit that referenced this pull request Jun 29, 2026
…lt perf

Temporarily git-pins buffa/buffa-build/buffa-descriptor to main (rev 64efc3b)
so CI/CodSpeed measures the encode/decode performance of #250's inline-by-default
sub-message representation (anthropics/buffa#257). Non-publishable while
git-pinned; revert to a crates.io version once buffa releases #250.

Inline-by-default changes the generated sender_chain field from Box to inline,
so DecryptSnapshot now stores Option<Chain> instead of MessageField<Chain> to
stay agnostic to buffa's sub-message representation.
jlucaso1 added a commit that referenced this pull request Jun 29, 2026
…lt perf

Temporarily git-pins buffa/buffa-build/buffa-descriptor to main (rev 64efc3b)
so CI/CodSpeed measures the encode/decode performance of #250's inline-by-default
sub-message representation (anthropics/buffa#257). Non-publishable while
git-pinned; revert to a crates.io version once buffa releases #250.

Inline-by-default changes the generated sender_chain field from Box to inline,
so DecryptSnapshot now stores Option<Chain> instead of MessageField<Chain> to
stay agnostic to buffa's sub-message representation.
jlucaso1 added a commit that referenced this pull request Jun 29, 2026
…lt perf

Temporarily git-pins buffa/buffa-build/buffa-descriptor to main (rev f1c8088)
so CI/CodSpeed measures the encode/decode performance of #250's inline-by-default
sub-message representation and #259's inlined Tag::new (anthropics/buffa#257).
Non-publishable while git-pinned; revert to a crates.io version once buffa
releases these.

Inline-by-default changes the generated sender_chain field from Box to inline,
so DecryptSnapshot now stores Option<Chain> instead of MessageField<Chain> to
stay agnostic to buffa's sub-message representation.
jlucaso1 added a commit that referenced this pull request Jun 29, 2026
…osion

buffa main defaults singular message fields to an inline representation
(#250). For WhatsApp's deep, many-optional-field messages that makes
size_of explode recursively — WebMessageInfo and HistorySyncMsg reach
~15 KB each, since every message variant becomes its own inline slot —
so decode and Vec growth turn into large struct memcpys. The history-sync
stream-drain bench spent ~90% of its time in memcpy moving 15 KB
HistorySyncMsg structs into a Vec and reallocating it.

Pin buffa-build's blanket box_type(PointerRepr::Box), restoring the
pre-inline boxed layout (WebMessageInfo 888 B, HistorySyncMsg 24 B) while
staying on main for #259's inlined Tag::new encode win. The DecryptSnapshot
and history-sync snapshot fields stay representation-agnostic, so this
composes with either layout.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant