Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
133 changes: 132 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,67 @@ 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.
if let Some(hex) = &content.const_bytes
&& let Some(bytes) = decode_hex(hex)
{
let lits = bytes
.iter()
.map(|b| format!("0x{b:02x}"))
.collect::<Vec<_>>()
.join(", ");
return vec![format!(
"{indent}{var_name} = {var_name}.bytes(vec![{lits}]);"
)];
}
// A caller-supplied byte buffer: thread a `Vec<u8>` spec field.
if content.kind == WapContentKind::Bytes {
let field = var_name.replacen("_node", "_content", 1);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
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 so a bad `const_bytes` degrades to no content rather than
/// panicking codegen.
fn decode_hex(s: &str) -> Option<Vec<u8>> {
if s.len() % 2 != 0 {
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 +1327,73 @@ 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
)]
);
}
}
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"
);
}
24 changes: 24 additions & 0 deletions crates/wa-scan/src/content_length_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,18 @@ pub(crate) fn build_pass(defs: &[ModuleDefinition], source: &str) -> ContentLeng
}
for (parent, tag, len) in collect_module(slice) {
let key = (parent, tag);
// Keep the lexicographically-smallest module name as the provenance, not
// the first one *seen* — the scan order follows bundle file order, so a
// first-wins tie-break makes `byteLengthSource` depend on input ordering
// and breaks the byte-identical-output guarantee when two modules pin the
// same (parent,tag,len).
source_module
.entry(key.clone())
.and_modify(|existing| {
if m.name < *existing {
*existing = m.name.clone();
}
})
.or_insert_with(|| m.name.clone());
seen_lengths.entry(key).or_default().insert(len);
}
Expand Down Expand Up @@ -348,6 +358,20 @@ mod tests {
assert_eq!(idx.get("product_list", "value"), None);
}

#[test]
fn byte_length_source_is_order_independent() {
// Two modules pin the same (parent, tag, length); the recorded provenance must
// be the lexicographically-smallest module name regardless of bundle order, so
// the generated `byteLengthSource` is byte-identical across a reordering of the
// input bundles (the byte-identical-output guarantee).
let a = r#"__d("Aaa",[],function(g,r,d,o,e,i,l){ l.p = function(u){ return u.child("skey").child("value").contentBytes(32); };},1);"#;
let z = r#"__d("Zzz",[],function(g,r,d,o,e,i,l){ l.p = function(u){ return u.child("skey").child("value").contentBytes(32); };},1);"#;
let az = format!("{a}\n{z}");
let za = format!("{z}\n{a}");
assert_eq!(index(&az).get("skey", "value"), Some((32, "Aaa")));
assert_eq!(index(&za).get("skey", "value"), Some((32, "Aaa")));
}

#[test]
fn contentuint_length_is_indexed() {
let bundle = r#"__d("P",[],function(g,r,d,o,e,i,l){
Expand Down
20 changes: 20 additions & 0 deletions crates/wa-scan/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ mod request;
mod response;
mod response_index;
mod response_smax;
mod send_parser_index;
mod srvreq;
mod stanza;

Expand Down Expand Up @@ -155,6 +156,12 @@ pub fn scan_iq_with_diagnostics(
// can't (`0x00` and `0x30` are both one byte).
let const_values = const_value_index::build_pass(module_defs, source);

// Build the send-site parser index once. A legacy job that hands a sibling
// module's parser to `deprecatedSendIq(iq, o("Mod").parser)` (rather than
// constructing one inline) would otherwise lose its typed response; this resolves
// the referenced parser, keyed by the sending module.
let send_parsers = send_parser_index::build_pass(module_defs, source);

let mut stanzas = Vec::new();
let mut unparseable = Vec::new();
let mut cross = CrossModuleStats::default();
Expand Down Expand Up @@ -199,6 +206,19 @@ pub fn scan_iq_with_diagnostics(
// unambiguous even where the request nests them differently than the parser does).
// A tag spanning namespaces (`<id>`: a key id in `encrypt`, a product id in
// `w:biz:catalog`) is excluded, so it's never mislabeled.
// Attach a send-site parser to any request left with the `unknown` fallback
// (the module built an `<iq>` but defined no inline parser and had no smax
// response) — e.g. `WAWebQueryMediaConnsJob`'s `<media_conn>` response, parsed
// from `o("WAMediaConnParser").mediaConnParser`.
for s in &mut stanzas {
if s.response.parser_name == "unknown"
&& let Some(resp) = send_parsers.get(&s.module_name)
{
s.response = resp.clone();
s.parser_name = resp.parser_name.clone();
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

let tag_fallback = single_namespace_content_tags(&stanzas);
for s in &mut stanzas {
// Pin constant leaf values first (`<link_code_pairing_nonce>` → one `0x00`
Expand Down
Loading
Loading