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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 205 additions & 1 deletion crates/wa-codegen/src/emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use std::collections::HashMap;

use wa_ir::wap;
use wa_ir::{ParsedField, ParsedFieldType, WapAttrKind, WapChildNode};
use wa_ir::{ParsedField, ParsedFieldType, WapAttrKind, WapChildNode, WapContentKind};

use crate::fields::{
child_content_type, flatten_same_node, is_attr_field, is_child_field, is_jid_kind,
Expand Down Expand Up @@ -606,6 +606,7 @@ pub(crate) fn emit_child_builder(
}
}
body.extend(emit_variant_groups(child, &var_name, indent, ctx));
body.extend(emit_node_content(child, &var_name, indent, ctx));
if !nested_var_names.is_empty() {
body.push(format!(
"{indent}{var_name} = {var_name}.children([{}]);",
Expand Down Expand Up @@ -722,6 +723,82 @@ fn emit_variant_groups(
build
}

/// Emit the leaf element content of a request node (`<value>`, `<signature>`, the
/// prekey `<link_code_pairing_nonce>`) — the byte payload the node carries between
/// its tags. Without this a key-material node builds empty and is unusable on the
/// wire. Two shapes, both `bytes`:
/// - a compile-time constant ([`WapContent::const_bytes`], e.g. the one-byte `00`
/// nonce) → a literal `.bytes(vec![0x00])`;
/// - a caller-supplied buffer (a bare `bytes` content, e.g. a prekey `signature`)
/// → a `Vec<u8>` spec field pushed to `ctx.fields` (mirroring
/// [`emit_variant_groups`]) and threaded in as `.bytes(self.<field>.clone())`.
///
/// The field name derives from the caller's collision-free `var_name` (`id_node` →
/// `id_content`, `id_node_2` → `id_content_2`) so repeated leaf tags across a
/// stanza (the three `<value>`/`<id>` nodes in a prekey `<skey>` tree) each get a
/// distinct field.
fn emit_node_content(
child: &WapChildNode,
var_name: &str,
indent: &str,
ctx: &mut VariantCtx,
) -> Vec<String> {
let Some(content) = &child.content else {
return Vec::new();
};
// A fixed byte constant: emit the literal buffer, no caller input needed. Handled
// in isolation — a `const_bytes` that fails to decode degrades to no content, and
// must never fall through to the caller-supplied-field branch below (that would
// turn a fixed constant into an unwanted `Vec<u8>` builder argument).
if let Some(hex) = &content.const_bytes {
return decode_hex(hex)
.map(|bytes| {
let lits = bytes
.iter()
.map(|b| format!("0x{b:02x}"))
.collect::<Vec<_>>()
.join(", ");
vec![format!(
"{indent}{var_name} = {var_name}.bytes(vec![{lits}]);"
)]
})
.unwrap_or_default();
}
// A caller-supplied byte buffer: thread a `Vec<u8>` spec field. Rename the
// *terminal* `_node` segment (the var is `{tag}_node` or `{tag}_node_{n}`) so a
// tag that itself contains `_node` isn't corrupted (`node_id_node` → `node_id_content`).
if content.kind == WapContentKind::Bytes {
let field = match var_name.rfind("_node") {
Some(pos) => format!(
"{}_content{}",
&var_name[..pos],
&var_name[pos + "_node".len()..]
),
None => format!("{var_name}_content"),
};
ctx.fields
.push((field.clone(), "Vec<u8>".to_string(), false));
return vec![format!(
"{indent}{var_name} = {var_name}.bytes(self.{field}.clone());"
)];
}
Vec::new()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Decode an even-length hex string (`"00"`, `"0a1b"`) to its bytes. Returns `None`
/// for malformed input (odd length, non-ASCII, or a non-hex digit) so a bad
/// `const_bytes` degrades to no content rather than panicking codegen. The
/// `is_ascii` gate also keeps the byte-index slicing below on char boundaries.
fn decode_hex(s: &str) -> Option<Vec<u8>> {
if s.len() % 2 != 0 || !s.is_ascii() {
return None;
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
.collect()
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/// A const attr present in every variant of a group with DISTINCT values is the
/// discriminator (e.g. `type` = `jid`/`invite`); variants are named by its value.
fn variant_discriminator(group: &wa_ir::WapVariantGroup) -> Option<String> {
Expand Down Expand Up @@ -1265,4 +1342,131 @@ mod tests {
"{code}"
);
}

#[test]
fn const_bytes_content_emits_literal_byte_buffer() {
use wa_ir::{WapContent, WapContentKind};
// The one-byte `00` link-code pairing nonce: a compile-time constant, so the
// builder writes the literal buffer directly (no caller input). Previously the
// node was built empty, making the request unusable on the wire.
let node = WapChildNode {
tag: "link_code_pairing_nonce".into(),
attrs: vec![],
children: vec![],
content: Some(WapContent {
kind: WapContentKind::Bytes,
byte_length: Some(1),
const_bytes: Some("00".into()),
..Default::default()
}),
repeats: false,
variant_groups: vec![],
};
let (lines, _) = build1(&node);
let code = lines.join("\n");
assert!(
code.contains(
"link_code_pairing_nonce_node = link_code_pairing_nonce_node.bytes(vec![0x00]);"
),
"{code}"
);
}

#[test]
fn dynamic_bytes_content_threads_a_vec_u8_spec_field() {
use wa_ir::{WapContent, WapContentKind};
// A prekey `<signature>` carries caller-supplied key material, so the builder
// threads a `Vec<u8>` spec field named after the (collision-free) var and
// writes it as the node content.
let node = WapChildNode {
tag: "signature".into(),
attrs: vec![],
children: vec![],
content: Some(WapContent {
kind: WapContentKind::Bytes,
byte_length: Some(64),
..Default::default()
}),
repeats: false,
variant_groups: vec![],
};
let (mut enums, mut fields) = (Vec::new(), Vec::new());
let mut ctx = VariantCtx {
spec_base: "T",
enum_defs: &mut enums,
fields: &mut fields,
};
let (lines, _) = emit_child_builder(&node, "", &mut HashMap::new(), &mut ctx);
let code = lines.join("\n");
assert!(
code.contains("signature_node = signature_node.bytes(self.signature_content.clone());"),
"{code}"
);
assert_eq!(
fields,
vec![(
"signature_content".to_string(),
"Vec<u8>".to_string(),
false
)]
);
}

#[test]
fn dynamic_content_field_renames_only_the_terminal_node_segment() {
use wa_ir::{WapContent, WapContentKind};
// A tag whose own name contains `_node` must not corrupt the field name: the
// var `node_id_node` yields `node_id_content`, not `node_content_node`.
let node = WapChildNode {
tag: "node_id".into(),
attrs: vec![],
children: vec![],
content: Some(WapContent {
kind: WapContentKind::Bytes,
byte_length: Some(8),
..Default::default()
}),
repeats: false,
variant_groups: vec![],
};
let (mut enums, mut fields) = (Vec::new(), Vec::new());
let mut ctx = VariantCtx {
spec_base: "T",
enum_defs: &mut enums,
fields: &mut fields,
};
emit_child_builder(&node, "", &mut HashMap::new(), &mut ctx);
assert_eq!(fields[0].0, "node_id_content");
}

#[test]
fn malformed_const_bytes_emits_nothing_and_adds_no_field() {
use wa_ir::{WapContent, WapContentKind};
// A `const_bytes` that can't be decoded degrades to no content — it must not
// fall through and be turned into a caller-supplied `Vec<u8>` field.
let node = WapChildNode {
tag: "nonce".into(),
attrs: vec![],
children: vec![],
content: Some(WapContent {
kind: WapContentKind::Bytes,
const_bytes: Some("zz".into()), // not hex
..Default::default()
}),
repeats: false,
variant_groups: vec![],
};
let (mut enums, mut fields) = (Vec::new(), Vec::new());
let mut ctx = VariantCtx {
spec_base: "T",
enum_defs: &mut enums,
fields: &mut fields,
};
let (lines, _) = emit_child_builder(&node, "", &mut HashMap::new(), &mut ctx);
assert!(fields.is_empty(), "no spec field for a malformed constant");
assert!(
!lines.join("\n").contains(".bytes("),
"no content emitted for a malformed constant"
);
}
}
13 changes: 13 additions & 0 deletions crates/wa-ir/src/iq.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,19 @@ pub struct ParsedField {
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub byte_length: Option<u32>,
/// Inclusive lower bound on the byte-content length, enforced via
/// `contentBytesRange(node, min, max)` when `min != max` — a payload-size *limit*
/// (a media buffer capped at 1 MiB, a token capped at 128 bytes), as opposed to a
/// fixed [`byte_length`] (the `min == max` case). Present only for a
/// [`ParsedFieldType::Bytes`] field with a true range; a consumer can enforce the
/// bound rather than guessing an unbounded buffer.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub byte_min: Option<u32>,
/// Inclusive upper bound from the same `contentBytesRange` check (see [`byte_min`]).
///
/// [`byte_min`]: ParsedField::byte_min
#[serde(default, skip_serializing_if = "Option::is_none")]
pub byte_max: Option<u32>,
/// Inclusive lower bound the parser enforces via `attrIntRange(node, name, min,
/// max)`. Present only for a bounded [`ParsedFieldType::Integer`]; a consumer can
/// validate or pick a narrower Rust width from these. The timestamp-marker range is
Expand Down
55 changes: 53 additions & 2 deletions crates/wa-notif/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ use oxc_allocator::Allocator;
use oxc_ast::ast::{Expression, Statement, SwitchCase, SwitchStatement, VariableDeclaration};
use oxc_ast_visit::{Visit, walk};
use wa_ir::{
AssertionKind, NotifIr, NotificationDef, ParsedResponse, StanzaTagDef, SubCase,
SubDiscriminant, SubDiscriminantOn,
AssertionKind, NotifIr, NotificationDef, ParsedResponse, ServerRequestTag, StanzaTagDef,
SubCase, SubDiscriminant, SubDiscriminantOn,
};
use wa_oxc::{
arg_expr, as_call, as_identifier, as_int, as_member, as_string_lit, callee_method,
Expand Down Expand Up @@ -123,6 +123,22 @@ pub fn extract_notif_from_modules(
n.content = notification_content(slice, &notif_type);
}

// Phase 2b: a type still degraded here has a handler that delegates parsing to
// the smax receive-RPC path rather than an inline `WADeprecatedWapParser`, so its
// field tree lives in a `WASmaxIn<X>...Request` module (the srvreq domain).
// Recover it by matching the server-request read-shape whose asserted `type`
// equals the notification's, keeping only unambiguous single-shape types.
if dispatch.notifications.iter().any(|n| n.content.is_none()) {
let srvreq_shapes = srvreq_notification_shapes(source, module_defs);
for n in &mut dispatch.notifications {
if n.content.is_none()
&& let Some(shape) = srvreq_shapes.get(&n.notif_type)
{
n.content = Some(shape.clone());
}
}
}

Comment thread
jlucaso1 marked this conversation as resolved.
NotifIr {
wa_version: wa_version.to_string(),
dispatcher_modules,
Expand Down Expand Up @@ -217,6 +233,41 @@ fn notification_content(handler_slice: &str, notif_type: &str) -> Option<ParsedR
pick_notification_parser(parsers, notif_type)
}

/// Typed notification content recovered from the server-request read-shapes (the
/// srvreq domain): notification `type` → the `WASmaxIn<X>...Request` shape whose
/// parser asserts that `type`. Only types with a single matching shape are kept, so
/// a type parsed by several distinct request modules stays degraded rather than being
/// bound to one arbitrary shape (mirroring [`pick_notification_parser`]'s caution).
fn srvreq_notification_shapes(
source: &str,
module_defs: &[ModuleDefinition],
) -> std::collections::HashMap<String, ParsedResponse> {
let asserted_type = |p: &ParsedResponse| -> Option<String> {
p.assertions.iter().find_map(|a| {
(a.kind == AssertionKind::Attr && a.name.as_deref() == Some("type"))
.then(|| a.value.clone())
.flatten()
})
};
let mut by_type: std::collections::HashMap<String, Vec<ParsedResponse>> =
std::collections::HashMap::new();
for def in wa_scan::scan_server_requests_from_modules(source, module_defs) {
if def.tag != ServerRequestTag::Notification {
continue;
}
if let Some(t) = asserted_type(&def.shape) {
by_type.entry(t).or_default().push(def.shape);
}
}
by_type
.into_iter()
.filter_map(|(t, mut shapes)| match shapes.len() {
1 => Some((t, shapes.pop().expect("len checked"))),
_ => None,
})
.collect()
}

/// Choose the parser for `notif_type` from a handler module's parsers.
///
/// A module can hold parsers for several notification types (each asserting its
Expand Down
55 changes: 55 additions & 0 deletions crates/wa-notif/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -509,3 +509,58 @@ fn extraction_is_deterministic() {
let b = serde_json::to_string(&ir()).unwrap();
assert_eq!(a, b);
}

/// A `<notification type="waffle">` handler that delegates to the smax receive-RPC
/// path (no inline `WADeprecatedWapParser`) plus the matching `WASmaxIn*Request`
/// read-shape module, so Phase 2b recovers the content from the srvreq domain.
const SRVREQ_LINK_BUNDLE: &str = r#"
__d("WAWebCommsHandleLoggedInStanza",["WAWebAccountLinkingNotificationHandler"],function(g,r,d,o,e,i,l){
l.handle = function(){ return (function*(e,t){
var n = e.attrs;
switch (e.tag) {
case "notification":
switch (n.type) {
case "waffle": return yield r("WAWebAccountLinkingNotificationHandler")(e);
}
}
}); };
}, 1);
__d("WASmaxInWaffleWFNotificationRequest",["WAResultOrError","WASmaxParseUtils"],(function(t,n,r,o,a,i,l){function e(e){var t=o("WASmaxParseUtils").assertTag(e,"notification");if(!t.success)return t;var n=o("WASmaxParseUtils").assertAttr(e,"type","waffle");if(!n.success)return n;var s=o("WASmaxParseUtils").attrString(e,"id");return s.success?o("WAResultOrError").makeResult({id:s.value}):s}l.parseWFNotificationRequest=e}),1);
"#;

#[test]
fn srvreq_read_shape_recovers_degraded_notification_content() {
let ir = extract_notif(SRVREQ_LINK_BUNDLE, "2.3000.test");
let content = notif(&ir, "waffle")
.content
.as_ref()
.expect("waffle content recovered from the srvreq read-shape");
assert_eq!(content.parser_name, "parseWFNotificationRequest");
assert!(content.fields.iter().any(|f| f.name == "id"));
}

#[test]
fn ambiguous_srvreq_type_leaves_notification_degraded() {
// Two request modules assert the same `type="hosted"`; without a unique shape the
// notification stays degraded rather than binding to an arbitrary one.
let bundle = r#"
__d("WAWebCommsHandleLoggedInStanza",["WAWebHandleHostedNotification"],function(g,r,d,o,e,i,l){
l.handle = function(){ return (function*(e,t){
var n = e.attrs;
switch (e.tag) {
case "notification":
switch (n.type) {
case "hosted": return yield r("WAWebHandleHostedNotification")(e);
}
}
}); };
}, 1);
__d("WASmaxInCoexistenceOnboardingStatusNotificationRequest",["WAResultOrError","WASmaxParseUtils"],(function(t,n,r,o,a,i,l){function e(e){var t=o("WASmaxParseUtils").assertTag(e,"notification");if(!t.success)return t;var n=o("WASmaxParseUtils").assertAttr(e,"type","hosted");if(!n.success)return n;var s=o("WASmaxParseUtils").attrString(e,"a");return s.success?o("WAResultOrError").makeResult({a:s.value}):s}l.parseOnboardingStatusNotificationRequest=e}),1);
__d("WASmaxInCoexistenceOffboardingNotificationRequest",["WAResultOrError","WASmaxParseUtils"],(function(t,n,r,o,a,i,l){function e(e){var t=o("WASmaxParseUtils").assertTag(e,"notification");if(!t.success)return t;var n=o("WASmaxParseUtils").assertAttr(e,"type","hosted");if(!n.success)return n;var s=o("WASmaxParseUtils").attrString(e,"b");return s.success?o("WAResultOrError").makeResult({b:s.value}):s}l.parseOffboardingNotificationRequest=e}),1);
"#;
let ir = extract_notif(bundle, "2.3000.test");
assert!(
notif(&ir, "hosted").content.is_none(),
"an ambiguous type (two read-shapes) must stay degraded"
);
}
Loading
Loading