diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37ccdfe8..7a8262a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,15 @@ jobs: pip install --quiet jsonschema python scripts/validate-schemas.py generated + # The schemas check SHAPE; this checks that the document is usable. A + # consumer generating code from the IR — in any language — has to trust + # that an enum names its values, a byte pin matches its length, a union + # has alternatives. Those are invariants JSON Schema cannot express, and + # the counted states are held to a baseline so a constraint that starts + # slipping through shows up as a rise rather than as silence. + - name: Check IR internal consistency + run: python scripts/lint-ir.py generated + # Reproducibility gate: restore the *exact* bundle set generated/ was built from # (from the durable release store, verified against the committed lockfile) and # regenerate in --check mode. Proves every committed generated/ is byte-for-byte diff --git a/Cargo.lock b/Cargo.lock index 5fc46dce..b41b9b8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1881,6 +1881,7 @@ dependencies = [ "oxc_allocator", "oxc_ast", "oxc_ast_visit", + "oxc_syntax", "wa-ir", "wa-oxc", "wa-transform", diff --git a/crates/wa-codegen/src/notif_export.rs b/crates/wa-codegen/src/notif_export.rs index 862454f8..89d7b485 100644 --- a/crates/wa-codegen/src/notif_export.rs +++ b/crates/wa-codegen/src/notif_export.rs @@ -383,6 +383,8 @@ fn emit_action_tables(notifications: &[NotificationDef]) -> String { l.push_str(" pub name: &'static str,\n"); l.push_str(" pub wire_tag: &'static str,\n"); l.push_str(" pub fields: &'static [NotifActionField],\n"); + l.push_str(" /// Whether every branch producing this action carries the list.\n"); + l.push_str(" pub required: bool,\n"); l.push_str("}\n\n"); l.push_str("/// One arm of a notification's payload action union.\n"); l.push_str("#[derive(Debug, Clone, Copy)]\n"); @@ -450,10 +452,11 @@ fn emit_action_tables(notifications: &[NotificationDef]) -> String { .iter() .map(|c| { format!( - "NotifActionChild {{ name: {}, wire_tag: {}, fields: {} }}", + "NotifActionChild {{ name: {}, wire_tag: {}, fields: {}, required: {} }}", rust_lit(&c.name), rust_lit(&c.wire_tag), - field_list(&c.fields) + field_list(&c.fields), + c.required ) }) .collect(); diff --git a/crates/wa-enums/Cargo.toml b/crates/wa-enums/Cargo.toml index ff044c46..4b754b54 100644 --- a/crates/wa-enums/Cargo.toml +++ b/crates/wa-enums/Cargo.toml @@ -12,6 +12,7 @@ description = "Native tooling: oxc AST extraction of WhatsApp Web's $InternalEnu oxc_allocator = { workspace = true } oxc_ast = { workspace = true } oxc_ast_visit = { workspace = true } +oxc_syntax = { workspace = true } wa-ir = { workspace = true } wa-oxc = { workspace = true } wa-transform = { workspace = true } diff --git a/crates/wa-enums/src/lib.rs b/crates/wa-enums/src/lib.rs index d39aec69..067018d7 100644 --- a/crates/wa-enums/src/lib.rs +++ b/crates/wa-enums/src/lib.rs @@ -6,7 +6,7 @@ //! and keep only enums whose values are all-integer or all-string literals. #![cfg(not(target_arch = "wasm32"))] -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use oxc_allocator::Allocator; use oxc_ast::ast::{ @@ -100,15 +100,216 @@ fn extract_from_module(slice: &str, module: &str) -> Vec { out } +/// Every name a function body hoists: its `var` declarations and its nested function +/// declarations, wherever in the body they are written. +/// +/// `var` is FUNCTION-scoped, so one inside an `if` or a loop binds for the whole body too; +/// nested functions are not descended into, because their own `var`s belong to them. +fn collect_hoisted(stmts: &[oxc_ast::ast::Statement], out: &mut HashSet) { + use oxc_ast::ast::Statement as S; + for stmt in stmts { + match stmt { + S::FunctionDeclaration(d) => { + if let Some(id) = &d.id { + out.insert(id.name.to_string()); + } + } + // ONLY `var` hoists to the function. A `let`/`const` in a nested block is + // scoped to that block, so treating it as a function-wide binding refused an + // outer composition written where the binding is not even in scope — the + // over-refusal this whole mechanism keeps having to avoid. + S::VariableDeclaration(d) if d.kind.is_var() => collect_declared(d, out), + S::BlockStatement(b) => collect_hoisted(&b.body, out), + S::IfStatement(i) => { + collect_hoisted(std::slice::from_ref(&i.consequent), out); + if let Some(alt) = &i.alternate { + collect_hoisted(std::slice::from_ref(alt), out); + } + } + // The HEADER declares too, and `var` there is hoisted across the function + // exactly as one in the body is — scanning only the body left + // `for (var e of xs)` invisible. + S::ForStatement(f) => { + if let Some(oxc_ast::ast::ForStatementInit::VariableDeclaration(d)) = &f.init + && d.kind.is_var() + { + collect_declared(d, out); + } + collect_hoisted(std::slice::from_ref(&f.body), out); + } + S::ForInStatement(f) => { + if let oxc_ast::ast::ForStatementLeft::VariableDeclaration(d) = &f.left + && d.kind.is_var() + { + collect_declared(d, out); + } + collect_hoisted(std::slice::from_ref(&f.body), out); + } + S::ForOfStatement(f) => { + if let oxc_ast::ast::ForStatementLeft::VariableDeclaration(d) = &f.left + && d.kind.is_var() + { + collect_declared(d, out); + } + collect_hoisted(std::slice::from_ref(&f.body), out); + } + S::WhileStatement(w) => collect_hoisted(std::slice::from_ref(&w.body), out), + S::DoWhileStatement(w) => collect_hoisted(std::slice::from_ref(&w.body), out), + S::TryStatement(t) => { + collect_hoisted(&t.block.body, out); + if let Some(h) = &t.handler { + collect_hoisted(&h.body.body, out); + } + if let Some(fin) = &t.finalizer { + collect_hoisted(&fin.body, out); + } + } + S::SwitchStatement(sw) => { + for case in &sw.cases { + collect_hoisted(&case.consequent, out); + } + } + S::LabeledStatement(l) => collect_hoisted(std::slice::from_ref(&l.body), out), + _ => {} + } + } +} + +/// Every plain identifier an assignment TARGET writes, however the pattern is spelled. +fn assignment_target_names(target: &oxc_ast::ast::AssignmentTarget) -> Vec { + let mut out = Vec::new(); + collect_target_names(target, &mut out); + out +} + +/// Recursive: a nested pattern (`({x: {e}} = obj)`) or a rest (`({...e} = obj)`) writes +/// just as much as a direct element does, and the first version of this looked only one +/// level deep. +fn collect_target_names(target: &oxc_ast::ast::AssignmentTarget, out: &mut Vec) { + use oxc_ast::ast::AssignmentTargetPattern as P; + if let Some(id) = target.get_identifier_name() { + out.push(id.to_string()); + return; + } + let Some(pattern) = target.as_assignment_target_pattern() else { + return; + }; + fn maybe_default(d: &oxc_ast::ast::AssignmentTargetMaybeDefault, out: &mut Vec) { + use oxc_ast::ast::AssignmentTargetMaybeDefault as D; + match d { + D::AssignmentTargetWithDefault(w) => collect_target_names(&w.binding, out), + _ => { + if let Some(t) = d.as_assignment_target() { + collect_target_names(t, out); + } + } + } + } + match pattern { + P::ArrayAssignmentTarget(a) => { + for el in a.elements.iter().flatten() { + maybe_default(el, out); + } + if let Some(rest) = &a.rest { + collect_target_names(&rest.target, out); + } + } + P::ObjectAssignmentTarget(o) => { + for prop in &o.properties { + match prop { + oxc_ast::ast::AssignmentTargetProperty::AssignmentTargetPropertyIdentifier( + p, + ) => out.push(p.binding.name.to_string()), + oxc_ast::ast::AssignmentTargetProperty::AssignmentTargetPropertyProperty(p) => { + maybe_default(&p.binding, out) + } + } + } + if let Some(rest) = &o.rest { + collect_target_names(&rest.target, out); + } + } + } +} + +/// The names a statement list binds LEXICALLY: `let`, `const` and `class`. +/// +/// One helper for all four places that needed it — a function body, an arrow body, a +/// block and a `switch`. Three hand-written copies had already drifted: the class arm was +/// added to two of them and the third kept resolving over a class binding. +fn collect_lexical(stmts: &[oxc_ast::ast::Statement], out: &mut HashSet) { + use oxc_ast::ast::Statement as S; + for stmt in stmts { + match stmt { + S::VariableDeclaration(d) if !d.kind.is_var() => collect_declared(d, out), + // `class e {}` binds lexically over its whole scope exactly as `let` does. + S::ClassDeclaration(c) => { + if let Some(id) = &c.id { + out.insert(id.name.to_string()); + } + } + // `class e {}` binds lexically over its whole scope exactly as `let` does. + _ => {} + } + } +} + +/// Every identifier one `var`/`let`/`const` declaration binds. +fn collect_declared(d: &oxc_ast::ast::VariableDeclaration, out: &mut HashSet) { + for decl in &d.declarations { + for id in decl.id.get_binding_identifiers() { + out.insert(id.name.to_string()); + } + } +} + +/// The identifier names a parameter list binds. +fn param_names(params: &oxc_ast::ast::FormalParameters) -> HashSet { + // EVERY identifier a parameter list binds, not just the plainly-named ones. A + // destructured parameter (`function f({e})`) binds `e` exactly as much as + // `function f(e)` does, and the rest element is not in `items` at all — both were + // invisible, so a composition inside such a function still resolved the module-level + // operand. `get_binding_identifiers` is the same traversal `wa_scan::shadow_params` + // uses for its own shadowing, rather than a second hand-rolled walk to drift from. + let mut out = HashSet::new(); + let mut add = |pattern: &oxc_ast::ast::BindingPattern| { + for ident in pattern.get_binding_identifiers() { + out.insert(ident.name.to_string()); + } + }; + for p in ¶ms.items { + add(&p.pattern); + } + if let Some(rest) = ¶ms.rest { + add(&rest.rest.argument); + } + out +} + /// Resolve `X.Name = local` bindings against the locals captured by var-init, filling /// `exports` (first binding wins). Shared by [`resolve_named_enum`] and the plain-object /// catalog pass so the two resolution sites can't drift. fn resolve_pending(r: &mut NamedResolver) { for (export, local) in &r.pending { + // Same refusal as `NamedResolver::local`, spelled out because the method borrows + // all of `r` while `exports` below needs a disjoint mutable borrow. An export + // bound to a name the module rebinds is exactly as unsafe to publish here. + if r.shadowed.contains(local) { + continue; + } if let Some(data) = r.locals.get(local) { - r.exports - .entry(export.clone()) - .or_insert_with(|| data.clone()); + // A deferred alias that DISAGREES with an inline write to the same export is + // the same conflict the immediate path already refuses — `i.OUT = {A:"a"}; + // i.OUT = e` exports `e` at runtime, and `or_insert` kept `{A}`. + match r.exports.get(export) { + Some(prev) if prev != data => { + r.conflicting.insert(export.clone()); + } + Some(_) => {} + None => { + r.exports.insert(export.clone(), data.clone()); + } + } } } } @@ -127,13 +328,13 @@ pub fn resolve_named_enum(module_slice: &str, module: &str, name: &str) -> Optio if ret.panicked { return None; } - let mut r = NamedResolver { - locals: HashMap::new(), - exports: HashMap::new(), - pending: Vec::new(), - }; + let mut r = NamedResolver::new(); + r.factory_params = module_factory_params(&ret.program); r.visit_program(&ret.program); resolve_pending(&mut r); + if r.conflicting.contains(name) { + return None; + } let (value_kind, variants) = r.exports.get(name)?.clone(); Some(InternalEnumDef { name: name.to_string(), @@ -143,6 +344,36 @@ pub fn resolve_named_enum(module_slice: &str, module: &str, name: &str) -> Optio }) } +/// The parameter names of the `__d("Name", deps, factory, id)` factory, if it is there. +/// +/// The export object is one of them, so a member assignment on anything else writes to a +/// private object rather than exporting. Which parameter it is varies by bundle arity, so +/// all of them are accepted — the point is to exclude module LOCALS. +fn module_factory_params(program: &oxc_ast::ast::Program) -> HashSet { + for stmt in &program.body { + let oxc_ast::ast::Statement::ExpressionStatement(es) = stmt else { + continue; + }; + let Some(call) = as_call(&es.expression) else { + continue; + }; + if as_identifier(&call.callee) != Some("__d") { + continue; + } + for arg in &call.arguments { + let Some(e) = arg_expr(arg) else { continue }; + let e = match e { + Expression::ParenthesizedExpression(p) => &p.expression, + other => other, + }; + if let Expression::FunctionExpression(f) = e { + return param_names(&f.params); + } + } + } + HashSet::new() +} + /// An enum-body object: either `$InternalEnum({…})` or a bare object literal. fn enum_object<'b, 'a>(e: &'b Expression<'a>) -> Option<&'b ObjectExpression<'a>> { internal_enum_object(e).or_else(|| as_object(e)) @@ -179,11 +410,8 @@ fn extract_plain_object_enums(slice: &str, module: &str) -> Vec if ret.panicked { return Vec::new(); } - let mut r = NamedResolver { - locals: HashMap::new(), - exports: HashMap::new(), - pending: Vec::new(), - }; + let mut r = NamedResolver::new(); + r.factory_params = module_factory_params(&ret.program); r.visit_program(&ret.program); resolve_pending(&mut r); let mut out: Vec = Vec::new(); @@ -203,27 +431,532 @@ fn extract_plain_object_enums(slice: &str, module: &str) -> Vec struct NamedResolver { locals: HashMap, + /// Names bound to two DIFFERENT enum bodies somewhere in the module. + /// + /// `locals` is flat while JavaScript is lexically scoped, so a minified module that + /// reuses a short name inside a nested function — `var e={A:"a"}` at the top, then + /// `function f(){var e={X:"x"}}` — overwrites the outer binding that a later + /// `extends({}, e, …)` still refers to at runtime. Publishing `X` there would be a + /// closed value set naming members the runtime never accepts, which is the one thing + /// this crate must not do; both readers of `locals` refuse such a name instead. + /// + /// Tracking real scopes would be the complete answer. Refusal is the cheap one that + /// cannot be wrong, and no bundle in the pinned set has a single collision — so the + /// choice costs nothing today and stays correct if that changes. + shadowed: HashSet, + /// Parameter names bound by the functions currently being visited, innermost last. + /// + /// A parameter shadows only INSIDE its own body, so unlike [`Self::shadowed`] this is + /// a stack rather than a module-wide set. Refusing such a name everywhere was measured + /// and costs real constraints — `STANZA_MSG_TYPES` resolves at module level in a + /// module that happens to reuse its local's name as a parameter elsewhere. + param_scopes: Vec>, + /// Whether the declaration currently being visited is a `var`. + /// + /// Only `var` reaches beyond its block, so only `var` may invalidate a module-level + /// name. A `let`/`const` in a nested block is invisible to a read written outside it, + /// and treating it as a shadow dropped a resolvable enum. + in_var_decl: bool, + /// The module factory's parameter names, when they could be identified. + /// + /// `X.Name = {…}` is only an EXPORT when `X` is one of them. Accepting it on any + /// object let a private `tmp.Target = {A:"a"}` be published as a named enum and + /// attached as a closed `enumRef` to protocol fields the runtime never validates + /// against it. Empty means "could not tell", and then nothing is narrowed. + factory_params: HashSet, exports: HashMap, + /// Exports written more than once with DIFFERENT bodies — refused rather than + /// resolved to whichever write this pass happened to see first. + conflicting: HashSet, pending: Vec<(String, String)>, } +impl NamedResolver { + fn new() -> Self { + Self { + locals: HashMap::new(), + shadowed: HashSet::new(), + param_scopes: Vec::new(), + in_var_decl: false, + factory_params: HashSet::new(), + exports: HashMap::new(), + conflicting: HashSet::new(), + pending: Vec::new(), + } + } + + /// The body bound to `name`, unless the module rebinds it to a different one. + fn local(&self, name: &str) -> Option<&EnumData> { + if self.shadowed.contains(name) || self.param_shadows(name) { + return None; + } + self.locals.get(name) + } + + /// Whether `e` names the module's export object — one of the factory's parameters. + /// + /// When the parameters could not be identified this accepts anything, which is the + /// behaviour that predates the check: narrowing on a guess would silently drop real + /// exports, and a private object being published is the rarer failure. + fn is_export_object(&self, e: &Expression) -> bool { + if self.factory_params.is_empty() { + return true; + } + let Some(name) = as_identifier(e) else { + return false; + }; + if !self.factory_params.contains(name) { + return false; + } + // Spelling is not enough: `function f(i) { i.OUT = {…} }` writes to the NESTED + // parameter, not to the module's export object. Frame 0 is the module factory + // itself — its parameters ARE the receivers — so a rebinding is any frame after + // it that carries the name. + // `param_scopes` frames beyond the module factory carry parameters AND hoisted + // `var`s, so `function f(){ var i = {}; i.OUT = … }` is caught by the same test. + !self.param_scopes.iter().skip(1).any(|s| s.contains(name)) + } + + /// Push a lexical scope for `stmts`' own `let`/`const`, if any collide with a local. + fn with_lexical<'a, R>( + &mut self, + stmts: &[oxc_ast::ast::Statement<'a>], + extra: &[&oxc_ast::ast::VariableDeclaration<'a>], + f: impl FnOnce(&mut Self) -> R, + ) -> R { + let mut lexical = HashSet::new(); + for d in extra { + if !d.kind.is_var() { + collect_declared(d, &mut lexical); + } + } + collect_lexical(stmts, &mut lexical); + lexical.retain(|n| self.locals.contains_key(n.as_str())); + let pushed = !lexical.is_empty(); + if pushed { + self.param_scopes.push(lexical); + } + let out = f(self); + if pushed { + self.param_scopes.pop(); + } + out + } + + /// Whether an enclosing function binds `name` as a parameter, so a read of it here + /// is that parameter and not the module local this pass recorded. + fn param_shadows(&self, name: &str) -> bool { + self.param_scopes.iter().any(|s| s.contains(name)) + } + + fn bind_local(&mut self, name: &str, data: EnumData) { + match self.locals.get(name) { + Some(prev) if *prev != data => { + self.shadowed.insert(name.to_string()); + } + _ => {} + } + self.locals.insert(name.to_string(), data); + } +} + +impl NamedResolver { + /// Evaluate `babelHelpers.extends(a, b, …)` into one enum, left to right. + /// + /// Each operand must be something already understood — an object literal, an + /// `$InternalEnum(…)`, or a local this pass has already bound. Anything else refuses + /// the whole thing rather than publishing the operands it could read: a partial + /// merge is a closed value set that omits members the runtime accepts, which is + /// worse than recording the loss. + /// + /// Later operands override earlier keys, as the runtime does. A single value kind is + /// required, so a string enum merged with an int one resolves to nothing. + /// The local an `extends(target, …)` MUTATES, when the target is a bare name. + /// + /// `extends` writes into its first argument, so after `extends(e, {B:"b"})` the + /// runtime's `e` has `B` too. Recovering the pre-merge body would publish an enum + /// that rejects a value it accepts — which the refusal in `merge_extends` prevents + /// for the RESULT, and which this exists to prevent for the target itself. + fn mutated_extends_target<'e>(e: &'e Expression) -> Option<&'e str> { + Self::mutated_extends_target_of(as_call(e)?) + } + + /// The local an `extends(target, …)` mutates, given the call itself. + fn mutated_extends_target_of<'e>(call: &'e oxc_ast::ast::CallExpression) -> Option<&'e str> { + let callee = wa_oxc::as_member(&call.callee)?; + if callee.1 != "extends" || as_identifier(callee.0) != Some("babelHelpers") { + return None; + } + as_identifier(arg_expr(call.arguments.first()?)?) + } + + fn merge_extends(&self, e: &Expression) -> Option { + let call = as_call(e)?; + let callee = wa_oxc::as_member(&call.callee)?; + if callee.1 != "extends" || as_identifier(callee.0) != Some("babelHelpers") { + return None; + } + // The first operand is the TARGET, and `extends` mutates it. WA's convention is + // `extends({}, …)`, which mutates a throwaway; `extends(base, {B:"b"})` would also + // add `B` to `base` itself, so recovering only the result would publish a `BASE` + // export that is missing a member the runtime has. Recovering the composition is + // not worth modelling the mutation, so a non-empty target refuses the whole thing. + match call + .arguments + .first() + .and_then(arg_expr) + .and_then(as_object) + { + Some(o) if o.properties.is_empty() => {} + _ => return None, + } + let mut kind: Option = None; + let mut merged: Vec = Vec::new(); + for arg in &call.arguments { + let operand = arg_expr(arg)?; + // An empty `{}` is the conventional first operand and contributes nothing — + // and `parse_enum` rejects it (no variants), so it has to be recognised + // before the refusal path or it kills every merge. + if let Some(o) = as_object(operand) + && o.properties.is_empty() + { + continue; + } + let (k, variants) = match enum_object(operand).and_then(parse_enum) { + Some(data) => data, + // A bare identifier must already be bound by this pass. + None => self.local(as_identifier(operand)?)?.clone(), + }; + if !variants.is_empty() { + match kind { + Some(prev) if prev != k => return None, + _ => kind = Some(k), + } + } + for v in variants { + match merged.iter_mut().find(|x| x.name == v.name) { + Some(existing) => *existing = v, + None => merged.push(v), + } + } + } + if merged.is_empty() { + return None; + } + Some((kind?, merged)) + } +} + impl<'a> Visit<'a> for NamedResolver { + fn visit_variable_declaration(&mut self, d: &oxc_ast::ast::VariableDeclaration<'a>) { + let was = std::mem::replace(&mut self.in_var_decl, d.kind.is_var()); + walk::walk_variable_declaration(self, d); + self.in_var_decl = was; + } + + fn visit_call_expression(&mut self, c: &oxc_ast::ast::CallExpression<'a>) { + // Wherever the call appears — a sequence, a logical operand, a condition test, a + // return value. Detecting it at the statement or declarator level missed every + // nested position, and the generic walk reaches all of them. + if let Some(target) = Self::mutated_extends_target_of(c) + && !self.param_shadows(target) + { + self.shadowed.insert(target.to_string()); + } + walk::walk_call_expression(self, c); + } + + fn visit_expression_statement(&mut self, st: &oxc_ast::ast::ExpressionStatement<'a>) { + // `extends(e, {B:"b"})` mutates `e` wherever it is written, not only as a + // declarator's initializer — a bare expression statement does it too, and the + // declarator path below could not see that. + // Under an assignment too: `tmp = extends(e, {B:"b"})` is an expression statement + // wrapping an assignment, so looking at the statement's expression alone found + // the assignment and not the call that does the mutating. + let inner = match &st.expression { + Expression::AssignmentExpression(a) => &a.right, + other => other, + }; + if let Some(target) = Self::mutated_extends_target(inner) + && !self.param_shadows(target) + { + self.shadowed.insert(target.to_string()); + } + walk::walk_expression_statement(self, st); + } + fn visit_variable_declarator(&mut self, d: &VariableDeclarator<'a>) { - if let Some(local) = d.id.get_identifier_name() - && let Some(obj) = d.init.as_ref().and_then(enum_object) - && let Some(data) = parse_enum(obj) + // `var x = extends(e, {B:"b"})` MUTATES `e`. `merge_extends` already refuses to + // publish `x`, but `e` itself kept its pre-merge body and a later `i.BASE = e` + // published an enum missing a member the runtime accepts. + if let Some(init) = &d.init + && let Some(target) = Self::mutated_extends_target(init) + && !self.param_shadows(target) { - self.locals.insert(local.to_string(), data); + self.shadowed.insert(target.to_string()); + } + if d.id.get_identifier_name().is_none() { + // A DESTRUCTURED declaration (`const {e} = obj`) binds `e` just as much as + // `var e = …` does, and `get_identifier_name` sees none of it — so the whole + // branch below was skipped and the outer body stayed usable. Refused on + // collision only, the same rule an unreadable initializer follows: a + // destructuring that shadows nothing costs nothing to ignore. + // + // The hoisted prescan already marks these LEXICALLY, so writing them into the + // module-wide set as well outlived the body that binds them and refused a + // later, unrelated outer composition. Same guard the simple-identifier path + // takes, for the same reason. + for ident in d.id.get_binding_identifiers() { + if !self.param_shadows(&ident.name) && self.locals.contains_key(ident.name.as_str()) + { + self.shadowed.insert(ident.name.to_string()); + } + } + } + if let Some(local) = d.id.get_identifier_name() { + let data = d + .init + .as_ref() + .and_then(enum_object) + .and_then(parse_enum) + // WA composes enums as well as declaring them: + // `VISIBILITY_WITH_ERROR = extends({}, VISIBILITY, {error: "error"})`. + // Recognising only literals left every composed one unresolvable, and the + // fields validating against them shipped as `"type": "enum"` with no + // values — 41 lost constraints, the largest single entry in + // `dropsByReason`. + .or_else(|| d.init.as_ref().and_then(|e| self.merge_extends(e))); + // A name bound by an enclosing parameter (or hoisted binding) is LOCAL to that + // body, so writing it into the module-wide `locals`/`shadowed` would let a + // write inside one function invalidate a valid module-level read elsewhere. + if self.param_shadows(&local) { + walk::walk_variable_declarator(self, d); + return; + } + match data { + Some(data) => self.bind_local(local.as_str(), data), + // A redeclaration this pass cannot READ is as much a shadow as one it + // can: `var e = getEnum()` in a nested scope leaves the outer body in + // `locals`, and a later composition would publish values the runtime + // never has. Only when the name is already bound — an unparseable `var` + // that shadows nothing costs nothing to ignore. + // `var` only: a block-scoped `let`/`const` cannot be what a read written + // outside its block sees. + None if self.in_var_decl && self.locals.contains_key(local.as_str()) => { + self.shadowed.insert(local.to_string()); + } + None => {} + } } walk::walk_variable_declarator(self, d); } + fn visit_function( + &mut self, + f: &oxc_ast::ast::Function<'a>, + flags: oxc_syntax::scope::ScopeFlags, + ) { + // Everything this body binds is HOISTED over all of it, so a declaration written + // AFTER a composition still binds the operand that composition reads. Recording + // them as the walk reached them was source-ordered and reversing the two lines + // slipped straight past, so the whole body is pre-scanned before descending. + // + // Lexical, not module-wide: the enclosing scope is what a nested `function e(){}` + // shadows, and poisoning `e` for the entire module would refuse a composition + // written outside this body that never sees the inner binding at all. + let mut hoisted = param_names(&f.params); + // A body's own top-level `let`/`const` binds for the WHOLE body — it does not + // hoist across blocks, but nothing in this body is outside it either. The + // block-scope visitor only sees nested blocks, so these needed collecting here. + if let Some(body) = &f.body { + let mut lexical = HashSet::new(); + collect_lexical(&body.statements, &mut lexical); + hoisted.extend( + lexical + .into_iter() + .filter(|n| self.locals.contains_key(n.as_str())), + ); + } + // A NAMED function expression binds its own name inside its body (`var g = + // function e(){ … e … }` sees the function, not an outer `e`). That name lives on + // `f.id`, not among the declarations collected from the enclosing body, so it was + // the one binding form the prescan could not see. + if let Some(id) = &f.id + && self.locals.contains_key(id.name.as_str()) + { + hoisted.insert(id.name.to_string()); + } + if let Some(body) = &f.body { + let mut declared = HashSet::new(); + collect_hoisted(&body.statements, &mut declared); + // Only the ones that SHADOW something already bound. The module body is itself + // a function, so an unconditional sweep made its own `var e = {…}` shadow the + // local it declares — every enum in the catalog refused itself. Parameters + // stay unconditional: they can never BE the module local. + // Kept when the name collides with an enum local — the shadow that matters + // for operand lookup — OR with a factory parameter, which is what + // `is_export_object` consults: `function f(){ var i = {}; i.OUT = … }` writes + // to a private object, and filtering on `locals` alone let it through because + // a receiver name is not an enum. + hoisted.extend(declared.into_iter().filter(|n| { + self.locals.contains_key(n.as_str()) || self.factory_params.contains(n) + })); + } + self.param_scopes.push(hoisted); + walk::walk_function(self, f, flags); + self.param_scopes.pop(); + } + + fn visit_block_statement(&mut self, b: &oxc_ast::ast::BlockStatement<'a>) { + // `let`/`const` bind for THIS block only. Excluding them from the function-wide + // prescan was right; leaving them untracked entirely was not — an unreadable + // `let e = get()` inside a block still shadows the outer `e` for reads written in + // that block. A scope active only while the block is visited says both at once. + self.with_lexical(&b.body, &[], |me| walk::walk_block_statement(me, b)); + } + + fn visit_for_in_statement(&mut self, f: &oxc_ast::ast::ForInStatement<'a>) { + let left = match &f.left { + oxc_ast::ast::ForStatementLeft::VariableDeclaration(d) => vec![&**d], + _ => vec![], + }; + // For `var`, the RHS is evaluated with no new binding in place, so it reads in the + // enclosing scope. For `let`/`const` the bound name is already in its TEMPORAL + // DEAD ZONE while the RHS runs — a read there throws rather than reaching an outer + // enum — so the scope covers the RHS too. + let lexical_head = matches!( + &f.left, + oxc_ast::ast::ForStatementLeft::VariableDeclaration(d) if !d.kind.is_var() + ); + if lexical_head { + self.with_lexical(&[], &left, |me| { + me.visit_expression(&f.right); + me.visit_statement(&f.body); + }); + } else { + self.visit_expression(&f.right); + self.with_lexical(&[], &left, |me| me.visit_statement(&f.body)); + } + } + + fn visit_switch_statement(&mut self, sw: &oxc_ast::ast::SwitchStatement<'a>) { + // A `case` consequent is not a `BlockStatement`, so its `let` was invisible — + // the same untracked-lexical hole as the loop headers below. The binding is + // scoped to the whole switch body, which is what the runtime does too. + let mut lexical = HashSet::new(); + for case in &sw.cases { + collect_lexical(&case.consequent, &mut lexical); + } + lexical.retain(|n| self.locals.contains_key(n.as_str())); + // The DISCRIMINANT is read where the switch is written, before any case binding + // exists, so it belongs to the enclosing scope. Visiting it under the pushed one + // only refused a resolvable composition — safe, but the point of modelling scopes + // is not to guess in either direction. + self.visit_expression(&sw.discriminant); + let pushed = !lexical.is_empty(); + if pushed { + self.param_scopes.push(lexical); + } + for case in &sw.cases { + if let Some(test) = &case.test { + self.visit_expression(test); + } + for stmt in &case.consequent { + self.visit_statement(stmt); + } + } + if pushed { + self.param_scopes.pop(); + } + } + fn visit_for_statement(&mut self, f: &oxc_ast::ast::ForStatement<'a>) { + let init = match &f.init { + Some(oxc_ast::ast::ForStatementInit::VariableDeclaration(d)) => vec![&**d], + _ => vec![], + }; + self.with_lexical(&[], &init, |me| walk::walk_for_statement(me, f)); + } + fn visit_for_of_statement(&mut self, f: &oxc_ast::ast::ForOfStatement<'a>) { + let left = match &f.left { + oxc_ast::ast::ForStatementLeft::VariableDeclaration(d) => vec![&**d], + _ => vec![], + }; + // For `var`, the RHS is evaluated with no new binding in place, so it reads in the + // enclosing scope. For `let`/`const` the bound name is already in its TEMPORAL + // DEAD ZONE while the RHS runs — a read there throws rather than reaching an outer + // enum — so the scope covers the RHS too. + let lexical_head = matches!( + &f.left, + oxc_ast::ast::ForStatementLeft::VariableDeclaration(d) if !d.kind.is_var() + ); + if lexical_head { + self.with_lexical(&[], &left, |me| { + me.visit_expression(&f.right); + me.visit_statement(&f.body); + }); + } else { + self.visit_expression(&f.right); + self.with_lexical(&[], &left, |me| me.visit_statement(&f.body)); + } + } + fn visit_catch_clause(&mut self, c: &oxc_ast::ast::CatchClause<'a>) { + // `catch (e)` binds `e` for the handler body exactly as a parameter binds it for + // a function body — the third form of the same shadow, after parameters and + // destructured declarations. + let names = c + .param + .as_ref() + .map(|p| { + p.pattern + .get_binding_identifiers() + .into_iter() + .map(|i| i.name.to_string()) + .collect() + }) + .unwrap_or_default(); + self.param_scopes.push(names); + walk::walk_catch_clause(self, c); + self.param_scopes.pop(); + } + + fn visit_arrow_function_expression(&mut self, f: &oxc_ast::ast::ArrowFunctionExpression<'a>) { + // A block-bodied arrow hoists exactly as a function does; only its parameters + // were being recorded. Its top-level `let`/`const` need the same treatment as a + // function body's: an arrow's `FunctionBody` is not a `BlockStatement`, so the + // block visitor never sees them either. + let mut hoisted = param_names(&f.params); + let mut declared = HashSet::new(); + collect_lexical(&f.body.statements, &mut declared); + collect_hoisted(&f.body.statements, &mut declared); + // Same two-way filter the function body uses: an enum local OR a factory + // parameter, the latter because a receiver name is never an enum. + hoisted.extend( + declared.into_iter().filter(|n| { + self.locals.contains_key(n.as_str()) || self.factory_params.contains(n) + }), + ); + self.param_scopes.push(hoisted); + walk::walk_arrow_function_expression(self, f); + self.param_scopes.pop(); + } + fn visit_assignment_expression(&mut self, a: &AssignmentExpression<'a>) { if let Some(m) = a.left.as_member_expression() && let Some(prop) = m.static_property_name() + && self.is_export_object(m.object()) { if let Some(data) = enum_object(&a.right).and_then(parse_enum) { - self.exports.entry(prop.to_string()).or_insert(data); + // First write wins, but a SECOND write with a different body means the + // module publishes one name for two value sets and this pass cannot say + // which a consumer will see. Refusing beats picking. + match self.exports.get(prop) { + Some(prev) if *prev != data => { + self.conflicting.insert(prop.to_string()); + } + _ => { + self.exports.entry(prop.to_string()).or_insert(data); + } + } } else if prop == "exports" && let Some(o) = as_object(&a.right) { @@ -232,13 +965,60 @@ impl<'a> Visit<'a> for NamedResolver { for (key, value) in wa_oxc::obj_props(o) { if let Some(data) = enum_object(value).and_then(parse_enum) { self.exports.entry(key.to_string()).or_insert(data); - } else if let Some(id) = as_identifier(value) { + } else if let Some(id) = as_identifier(value) + // The same deferred-alias guard as the single-property branch + // below: `pending` resolves after the walk, when the parameter + // stack is unwound, so a parameter-shadowed name has to be + // refused HERE or it silently resolves to the module local. + && !self.param_shadows(id) + { self.pending.push((key.to_string(), id.to_string())); } } - } else if let Some(id) = as_identifier(&a.right) { + } else if let Some(id) = as_identifier(&a.right) + // `pending` is resolved AFTER the walk, when the parameter stack is + // already unwound, so the scope has to be recorded now: `function f(e){ + // i.OUT = e }` would otherwise export the module-local `e`. + && !self.param_shadows(id) + { self.pending.push((prop.to_string(), id.to_string())); } + } else if a.left.get_identifier_name().is_none() && a.left.as_member_expression().is_none() + { + // A destructuring ASSIGNMENT (`({e} = obj)`) rebinds without declaring, so no + // declarator ever runs and the cached body survived a write that replaced it. + for name in assignment_target_names(&a.left) { + if !self.param_shadows(&name) && self.locals.contains_key(name.as_str()) { + self.shadowed.insert(name); + } + } + } else if let Some(name) = a.left.get_identifier_name() { + // A bare `e = …` rebinding, which `visit_variable_declarator` never sees. + // Without this, only `var`-form rebindings reached `shadowed`, so a module + // that reassigns an operand before composing it still published the stale + // body — the same wrong closed set the shadow guard was added to prevent, + // reached through the one form it did not watch. + // Deliberately does NOT fall back to `merge_extends` the way the `var` form + // does. A self-referential rebind (`e = extends({}, e, {B:"b"})`) is only + // resolvable if you know whether a given read of `e` happens before or after + // it, and this pass has no ordering model for reads — so publishing the + // merged set would over-claim for every read that precedes the composition. + // Refusing costs nothing here: the merge yields a body different from the one + // already bound, so `bind_local` would mark the name shadowed anyway. + if self.param_shadows(name) { + // Local to the body that binds it — see the declarator path. + walk::walk_assignment_expression(self, a); + return; + } + match enum_object(&a.right).and_then(parse_enum) { + Some(data) => self.bind_local(name, data), + // Rebound to something this pass cannot read: whatever `locals` still + // holds for the name is no longer what the runtime has, so the name is + // no more usable than an ambiguous one. + None => { + self.shadowed.insert(name.to_string()); + } + } } walk::walk_assignment_expression(self, a); } @@ -408,6 +1188,535 @@ fn parse_enum(obj: &ObjectExpression) -> Option { mod tests { use super::*; + #[test] + fn a_composed_enum_merges_its_operands() { + // WA composes enums as well as declaring them, and the composed ones are the + // interesting ones: `VISIBILITY_WITH_ERROR` is `VISIBILITY` plus the `error` + // sentinel the parser checks for. Recognising only literals left 41 constraints + // unresolvable — the largest single entry in `dropsByReason`. + let module = r#"__d("WAWebPrivacySettings",[],(function(t,n,r,o,a,i){ + var e={all:"all",contacts:"contacts",none:"none"}, + l=babelHelpers.extends({},e,{error:"error"}); + i.VISIBILITY=e,i.VISIBILITY_WITH_ERROR=l + }),66);"#; + let def = resolve_named_enum(module, "WAWebPrivacySettings", "VISIBILITY_WITH_ERROR") + .expect("the composed export resolves"); + let values: Vec<&str> = def + .variants + .iter() + .map(|v| match &v.value { + Scalar::Str(s) => s.as_str(), + _ => "", + }) + .collect(); + assert_eq!( + values, + ["all", "contacts", "none", "error"], + "in merge order" + ); + // The base is still resolvable on its own, and does NOT gain the sentinel. + let base = resolve_named_enum(module, "WAWebPrivacySettings", "VISIBILITY").unwrap(); + assert_eq!(base.variants.len(), 3); + } + + #[test] + fn a_composition_that_mutates_its_target_is_refused() { + // `extends(base, {B:"b"})` adds `B` to `base` ITSELF at runtime. Recovering only + // the result would publish a `BASE` export missing a member the runtime has — + // an enum that claims to reject a value it accepts. WA's convention is an empty + // target, so requiring one costs nothing and rules the mutation out. + let module = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"},l=babelHelpers.extends(e,{B:"b"}); + i.BASE=e,i.WITH=l + }),1);"#; + assert!(resolve_named_enum(module, "M", "WITH").is_none()); + // ...and the TARGET is refused too. This once asserted that `BASE` still resolved + // with its one written member — which is precisely the enum "claiming to reject a + // value it accepts" that the paragraph above calls out. The comment described the + // hazard and the assertion below it enshrined the hazard; refusing is the only + // answer consistent with both. + assert!( + resolve_named_enum(module, "M", "BASE").is_none(), + "`extends` wrote `B` into `e`, so the pre-merge body is not what the runtime has" + ); + } + + #[test] + fn a_name_the_module_rebinds_is_refused_everywhere_it_is_read() { + // `locals` is flat; JavaScript is not. A minifier that reuses `e` inside a nested + // function must not leak into the outer binding the composition below reads. This + // once asserted REFUSAL on both, which was the conservative approximation of a + // resolver that could not tell an inner `e` from an outer one; now that parameter, + // hoisted and catch scopes are modelled, the honest answer is the lexical one — + // and keeping a refusal here would throw away two resolvable enums. What still + // refuses is ambiguity the pass genuinely cannot order, below. + let module = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(){var e={X:"x"};return e} + var l=babelHelpers.extends({},e,{B:"b"}); + i.WITH=l,i.ALIAS=e + }),1);"#; + // Now that the pass models scopes, the honest answer here is to RESOLVE: the inner + // `var e` is local to `f`, and both reads below are outer ones that never see it. + // The refusal this once asserted was the conservative approximation of a resolver + // that could not tell the two apart — worth keeping only while that was true. + assert_eq!( + resolve_named_enum(module, "M", "WITH") + .expect("the outer composition is unambiguous") + .variants + .len(), + 2, + "outer `e` plus `B`, not the inner `{{X}}`" + ); + assert_eq!( + resolve_named_enum(module, "M", "ALIAS") + .expect("the outer alias is unambiguous") + .variants + .len(), + 1 + ); + // A bare `e = …` rebinding is invisible to `visit_variable_declarator`, so only + // the `var` form reached `shadowed` at first — the same stale-body bug through + // the one write form the guard did not watch. + let assigned = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(){e={X:"x"}} + var l=babelHelpers.extends({},e,{B:"b"}); + i.WITH=l,i.ALIAS=e + }),1);"#; + assert!(resolve_named_enum(assigned, "M", "WITH").is_none()); + assert!(resolve_named_enum(assigned, "M", "ALIAS").is_none()); + + // Rebound to something unreadable is no better: whatever `locals` still holds is + // not what the runtime has. + let opaque = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(){e=computed()} + i.ALIAS=e + }),1);"#; + assert!(resolve_named_enum(opaque, "M", "ALIAS").is_none()); + + // A PARAMETER binds the name for its whole body, which neither the declarator nor + // the assignment path sees. + let param = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(e){ var l=babelHelpers.extends({},e,{B:"b"}); i.WITH=l } + f({X:"x"}) + }),1);"#; + assert!(resolve_named_enum(param, "M", "WITH").is_none()); + + // ...but only INSIDE that body. Refusing the name module-wide was measured against + // the pinned bundles and lost a real constraint (`STANZA_MSG_TYPES`), so a merge + // outside the shadowing function must still resolve. + let outside = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(e){ return e } + var l=babelHelpers.extends({},e,{B:"b"}); + i.WITH=l + }),1);"#; + let def = resolve_named_enum(outside, "M", "WITH").expect("resolves outside the body"); + assert_eq!(def.variants.len(), 2); + + // A redeclaration this pass cannot READ shadows just as much as one it can. + let unreadable = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(){ var e=getEnum(); var l=babelHelpers.extends({},e,{B:"b"}); i.OUT=l } + }),1);"#; + assert!(resolve_named_enum(unreadable, "M", "OUT").is_none()); + + // A deferred alias (`i.OUT = e`) is resolved after the walk, when the parameter + // stack is already unwound — so the shadow has to be recorded at the assignment. + let deferred = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(e){ i.OUT=e } + f({X:"x"}) + }),1);"#; + assert!(resolve_named_enum(deferred, "M", "OUT").is_none()); + + // A DESTRUCTURED parameter binds its names too, and a rest element is not even + // in `items` — both were invisible to the plain-name lookup. + let destructured = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f({e}){ var x=babelHelpers.extends({},e,{B:"b"}); i.OUT=x } + f({e:{X:"x"}}) + }),1);"#; + assert!(resolve_named_enum(destructured, "M", "OUT").is_none()); + + let rest = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(q, ...e){ var x=babelHelpers.extends({},e,{B:"b"}); i.OUT=x } + }),1);"#; + assert!(resolve_named_enum(rest, "M", "OUT").is_none()); + + // The exports BAG defers aliases the same way the single-property form does, so + // it needs the same parameter guard. + let bag = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(e){ i.exports={OUT:e} } + f({X:"x"}) + }),1);"#; + assert!(resolve_named_enum(bag, "M", "OUT").is_none()); + + // A bare rebind to a composition is REFUSED, not resolved: which body a read of + // `e` sees depends on whether it precedes the rebind, and nothing here models + // that order. See the note at the assignment branch. + let composed = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + e=babelHelpers.extends({},e,{B:"b"}); + i.OUT=e + }),1);"#; + assert!(resolve_named_enum(composed, "M", "OUT").is_none()); + + // A DESTRUCTURED declaration binds its names too, and `get_identifier_name` sees + // none of them. + let destructured_var = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(obj){ var {e}=obj; var x=babelHelpers.extends({},e,{B:"b"}); i.OUT=x } + }),1);"#; + assert!(resolve_named_enum(destructured_var, "M", "OUT").is_none()); + + // `catch (e)` binds for the handler body — the third form of the same shadow. + let caught = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + try { risky() } catch(e) { var x=babelHelpers.extends({},e,{B:"b"}); i.OUT=x } + }),1);"#; + assert!(resolve_named_enum(caught, "M", "OUT").is_none()); + // Sanity: the SAME shape without the catch binding must resolve, or the assertion + // above proves nothing about catch. + let no_catch = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + try { risky() } catch(q) { var x=babelHelpers.extends({},e,{B:"b"}); i.OUT=x } + }),1);"#; + assert_eq!( + resolve_named_enum(no_catch, "M", "OUT") + .expect("resolves when catch binds another name") + .variants + .len(), + 2 + ); + + // A nested `function e(){}` hoists a binding over the enclosing body. + let fn_name = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function g(){ function e(){} var x=babelHelpers.extends({},e,{B:"b"}); i.OUT=x } + }),1);"#; + assert!(resolve_named_enum(fn_name, "M", "OUT").is_none()); + + // ...and hoisting means the declaration may come AFTER the composition that reads + // the name. A source-ordered check misses exactly this order. + let hoisted_after = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function g(){ var x=babelHelpers.extends({},e,{B:"b"}); function e(){} i.OUT=x } + }),1);"#; + assert!(resolve_named_enum(hoisted_after, "M", "OUT").is_none()); + + // `var` is hoisted over the whole function too, so one written AFTER the + // composition still binds the operand it reads. + let var_after = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function g(){ var x=babelHelpers.extends({},e,{B:"b"}); var e={X:"x"}; i.OUT=x } + }),1);"#; + assert!(resolve_named_enum(var_after, "M", "OUT").is_none()); + + // ...and a `var` nested in a block is function-scoped all the same. + let var_in_block = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function g(){ var x=babelHelpers.extends({},e,{B:"b"}); if (c) { var e={X:"x"} } i.OUT=x } + }),1);"#; + assert!(resolve_named_enum(var_in_block, "M", "OUT").is_none()); + + // A named function EXPRESSION binds its own name inside its body. + let self_named = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + var f=function e(){ var x=babelHelpers.extends({},e,{B:"b"}); i.OUT=x }; + }),1);"#; + assert!(resolve_named_enum(self_named, "M", "OUT").is_none()); + + // A destructured shadow is LEXICAL: after the body that binds it, an unrelated + // outer composition must still resolve. + let outer_after_destructure = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(obj){ var {e}=obj; return e } + var l=babelHelpers.extends({},e,{B:"b"}); + i.OUT=l + }),1);"#; + assert_eq!( + resolve_named_enum(outer_after_destructure, "M", "OUT") + .expect("the outer composition never sees the inner binding") + .variants + .len(), + 2 + ); + + // A loop HEADER declares too, and `var` there hoists across the function. All + // three forms, because each takes a different arm of the prescan. + for header in [ + "for (var e = 0; e < 1; e++) {}", + "for (var e in xs) {}", + "for (var e of xs) {}", + ] { + let src = format!( + r#"__d("M",[],(function(t,n,r,o,a,i){{ + var e={{A:"a"}}; + function f(xs){{ var x=babelHelpers.extends({{}},e,{{B:"b"}}); {header} i.OUT=x }} + }}),1);"# + ); + assert!( + resolve_named_enum(&src, "M", "OUT").is_none(), + "the header's `var e` binds for the whole function: {header}" + ); + } + + // A destructuring ASSIGNMENT rebinds without declaring, so no declarator runs. + let destructure_assign = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + ({e}=obj); + var x=babelHelpers.extends({},e,{B:"b"}); + i.OUT=x + }),1);"#; + assert!(resolve_named_enum(destructure_assign, "M", "OUT").is_none()); + + // Nested and rest patterns write just as much as a direct element does. + for pat in [ + "({x: {e}} = obj)", + "({...e} = obj)", + "([[e]] = xs)", + "({e = 1} = obj)", + ] { + let src = format!( + r#"__d("M",[],(function(t,n,r,o,a,i){{ + var e={{A:"a"}}; + {pat}; + var x=babelHelpers.extends({{}},e,{{B:"b"}}); + i.OUT=x + }}),1);"# + ); + assert!( + resolve_named_enum(&src, "M", "OUT").is_none(), + "the pattern rebinds `e`: {pat}" + ); + } + + // A `let` in a NESTED BLOCK is scoped to that block, so a composition written + // outside it must still resolve — hoisting it function-wide was over-refusal. + let block_let = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(){ { let e = 1; } var x=babelHelpers.extends({},e,{B:"b"}); i.OUT=x } + }),1);"#; + assert_eq!( + resolve_named_enum(block_let, "M", "OUT") + .expect("a block-scoped `let` does not shadow the outer read") + .variants + .len(), + 2 + ); + + // A `let` shadows INSIDE its block, even though it does not hoist to the function. + let block_let_inside = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + { let e=get(); var x=babelHelpers.extends({},e,{B:"b"}); i.OUT=x } + }),1);"#; + assert!(resolve_named_enum(block_let_inside, "M", "OUT").is_none()); + + // `X.Name = {…}` is an EXPORT only when `X` is the module's export object. A + // private local assigned the same way would otherwise be published as a named + // enum and attached as a closed set to fields the runtime never checks against it. + let private_obj = r#"__d("M",[],(function(t,n,r,o,a,i){ + var tmp={}; + tmp.Target={A:"a",B:"b"}; + }),1);"#; + assert!(resolve_named_enum(private_obj, "M", "Target").is_none()); + // ...and the real export object still works. + let real_export = r#"__d("M",[],(function(t,n,r,o,a,i){ + i.Target={A:"a",B:"b"}; + }),1);"#; + assert_eq!( + resolve_named_enum(real_export, "M", "Target") + .expect("an assignment on the factory's export param resolves") + .variants + .len(), + 2 + ); + + // A lexical binding in a loop HEADER or a `switch` case shadows inside it too — + // neither is a `BlockStatement`, so both were invisible. + for shape in [ + "for (let e = get(); ; ) { var x=babelHelpers.extends({},e,{B:\"b\"}); i.OUT=x }", + "for (const e of xs) { var x=babelHelpers.extends({},e,{B:\"b\"}); i.OUT=x }", + "switch (k) { case 1: let e=get(); var x=babelHelpers.extends({},e,{B:\"b\"}); i.OUT=x }", + ] { + let src = format!( + r#"__d("M",[],(function(t,n,r,o,a,i){{ + var e={{A:"a"}}; + function f(xs,k){{ {shape} }} + }}),1);"# + ); + assert!( + resolve_named_enum(&src, "M", "OUT").is_none(), + "the lexical binding shadows inside its construct: {shape}" + ); + } + + // A body's own top-level `let` binds for the whole body, including a read written + // before it — no block encloses it, so the block visitor never sees it. + let body_let = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(){ var x=babelHelpers.extends({},e,{B:"b"}); let e=get(); i.OUT=x } + }),1);"#; + assert!(resolve_named_enum(body_let, "M", "OUT").is_none()); + + // An arrow's body is not a `BlockStatement` either, so its own top-level `let` + // was as invisible as a function body's was. + let arrow_let = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + var f=()=>{ let e=get(); var x=babelHelpers.extends({},e,{B:"b"}); i.OUT=x }; + }),1);"#; + assert!(resolve_named_enum(arrow_let, "M", "OUT").is_none()); + + // `class e {}` binds lexically over its scope exactly as `let` does. + for shape in [ + "function g(){ class e {} var x=babelHelpers.extends({},e,{B:\"b\"}); i.OUT=x }", + "function g(){ { class e {} var x=babelHelpers.extends({},e,{B:\"b\"}); i.OUT=x } }", + ] { + let src = format!( + r#"__d("M",[],(function(t,n,r,o,a,i){{ + var e={{A:"a"}}; + {shape} + }}),1);"# + ); + assert!( + resolve_named_enum(&src, "M", "OUT").is_none(), + "the class binding shadows: {shape}" + ); + } + + // A nested function that SHADOWS the export receiver writes to its own parameter. + let shadowed_receiver = r#"__d("M",[],(function(t,n,r,o,a,i){ + function f(i){ i.OUT={A:"a",B:"b"} } + }),1);"#; + assert!(resolve_named_enum(shadowed_receiver, "M", "OUT").is_none()); + // ...while the module's own receiver still exports. + let real = r#"__d("M",[],(function(t,n,r,o,a,i){ + function f(){ i.OUT={A:"a",B:"b"} } + }),1);"#; + assert_eq!( + resolve_named_enum(real, "M", "OUT") + .expect("the module's export object still works from a nested function") + .variants + .len(), + 2 + ); + + // A `var` that shadows the export receiver is the same hazard as a parameter. + let var_receiver = r#"__d("M",[],(function(t,n,r,o,a,i){ + function f(){ var i={}; i.OUT={A:"a",B:"b"} } + }),1);"#; + assert!(resolve_named_enum(var_receiver, "M", "OUT").is_none()); + + // `extends(e, …)` mutates `e` as a bare STATEMENT too, not only as an initializer. + let stmt_mutation = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + babelHelpers.extends(e,{B:"b"}); + i.BASE=e + }),1);"#; + assert!(resolve_named_enum(stmt_mutation, "M", "BASE").is_none()); + + // An ARROW shadowing the receiver is the same hazard as a function doing it. + let arrow_receiver = r#"__d("M",[],(function(t,n,r,o,a,i){ + var f=()=>{ var i={}; i.OUT={A:"a",B:"b"} }; + }),1);"#; + assert!(resolve_named_enum(arrow_receiver, "M", "OUT").is_none()); + + // `tmp = extends(e, …)` mutates `e` just as the bare call does. + let assigned_mutation = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"},tmp; + tmp=babelHelpers.extends(e,{B:"b"}); + i.BASE=e + }),1);"#; + assert!(resolve_named_enum(assigned_mutation, "M", "BASE").is_none()); + + // Two writes to one export with DIFFERENT bodies: which one a consumer sees is + // not something this pass can say, so it says nothing. + let conflicting = r#"__d("M",[],(function(t,n,r,o,a,i){ + i.OUT={A:"a"}; + i.OUT={X:"x"}; + }),1);"#; + assert!(resolve_named_enum(conflicting, "M", "OUT").is_none()); + // ...but an identical repeat is not a conflict. + let same_twice = r#"__d("M",[],(function(t,n,r,o,a,i){ + i.OUT={A:"a",B:"b"}; + i.OUT={A:"a",B:"b"}; + }),1);"#; + assert_eq!( + resolve_named_enum(same_twice, "M", "OUT") + .expect("an identical repeat still resolves") + .variants + .len(), + 2 + ); + + // A mutating `extends` NESTED in another expression mutates all the same. + for shape in [ + "(babelHelpers.extends(e,{B:\"b\"}), 0);", + "c && babelHelpers.extends(e,{B:\"b\"});", + "if (babelHelpers.extends(e,{B:\"b\"})) {}", + ] { + let src = format!( + r#"__d("M",[],(function(t,n,r,o,a,i){{ + var e={{A:"a"}}; + {shape} + i.BASE=e + }}),1);"# + ); + assert!( + resolve_named_enum(&src, "M", "BASE").is_none(), + "the nested call mutates `e`: {shape}" + ); + } + + // A LEXICAL loop head puts its name in the TDZ while the RHS runs, so a read there + // throws rather than reaching the outer enum — `var` is the opposite. + let tdz = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(){ for (let e of (i.OUT=e, [])) {} } + }),1);"#; + assert!(resolve_named_enum(tdz, "M", "OUT").is_none()); + + // A DEFERRED alias that disagrees with an inline write to the same export. + let alias_conflict = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={B:"b"}; + i.OUT={A:"a"}; + i.OUT=e + }),1);"#; + assert!(resolve_named_enum(alias_conflict, "M", "OUT").is_none()); + + // A name rebound to the SAME body is not ambiguous — refusing it would throw away + // a resolvable enum for nothing. + let same = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={A:"a"}; + function f(){var e={A:"a"};return e} + i.ALIAS=e + }),1);"#; + assert_eq!( + resolve_named_enum(same, "M", "ALIAS") + .expect("an identical rebinding is still resolvable") + .variants + .len(), + 1 + ); + } + + #[test] + fn a_composition_over_something_unreadable_is_refused() { + // A partial merge is a CLOSED value set missing members the runtime accepts — + // worse than recording the loss, so an unresolvable operand refuses the whole. + let module = r#"__d("M",[],(function(t,n,r,o,a,i){ + var e={all:"all"},l=babelHelpers.extends({},e,computed()); + i.WITH=l + }),1);"#; + assert!(resolve_named_enum(module, "M", "WITH").is_none()); + } + fn run(src: &str) -> Vec { let defs = wa_transform::extract_module_definitions(src); extract_enums_from_modules(src, &defs, "1.0").enums diff --git a/crates/wa-fetch/src/bootloader.rs b/crates/wa-fetch/src/bootloader.rs index fe3fa56c..3dad6934 100644 --- a/crates/wa-fetch/src/bootloader.rs +++ b/crates/wa-fetch/src/bootloader.rs @@ -24,7 +24,7 @@ //! //! WASM-safe: everything here goes through the [`HttpClient`] port. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use serde_json::Value; @@ -78,6 +78,10 @@ impl Default for WasmResolveOptions { pub struct WasmResolution { /// `bx` id → wasm URL, for every wasm handle resolved (page + endpoint). pub by_id: BTreeMap, + /// Every `bx` id the response MENTIONED, before the wasm/CDN filters. `by_id` holds + /// only the survivors, so absence from it means either "rejected" or "never seen" — + /// and a merge against a prior map needs to distinguish the two. + pub observed_ids: BTreeSet, /// Wasm URLs, deduped and sorted — what the downloader consumes. Includes any URL /// discovered by text scan that no `bx` id claimed. pub urls: Vec, @@ -87,9 +91,15 @@ pub struct WasmResolution { pub rounds: usize, /// Requests actually sent. pub requests: usize, - /// Requests that failed or returned a non-2xx / unparseable body, as diagnostics. - /// Non-fatal: resolution degrades to whatever the other requests returned. + /// Everything that degraded resolution, as diagnostics — a failed request, but also + /// a page that shipped no endpoint parameters or deferred no components. Non-fatal: + /// resolution degrades to whatever the other requests returned. pub failures: Vec, + /// Of those, the ones that were an actual request failing. Counted separately because + /// "requests that failed" and "reasons resolution was degraded" are different + /// questions, and a pinned request-health number that includes a pre-request + /// diagnostic contradicts its own name. + pub failed_requests: usize, } impl WasmResolution { @@ -154,7 +164,10 @@ pub fn resolve_wasm_with( out.requests += 1; match fetch_payload(client, &url) { Ok(payload) => collect_bx_data(&payload, &mut bx), - Err(why) => out.failures.push(format!("{param} round {round}: {why}")), + Err(why) => { + out.failures.push(format!("{param} round {round}: {why}")); + out.failed_requests += 1; + } } } out.rounds += 1; @@ -184,6 +197,10 @@ pub fn resolve_wasm_with( let mut urls: Vec = Vec::new(); for (id, uri) in bx { + // Recorded whether or not the binding survives the filters below: a caller + // merging a PRIOR map has to tell "this id was rebound to something we refuse" + // from "this id was never mentioned", and only the pre-filter set can say that. + out.observed_ids.insert(id.clone()); if is_wasm_url(&uri) && is_cdn_payload(&uri) { urls.push(uri.clone()); out.by_id.insert(id, uri); @@ -217,7 +234,7 @@ pub fn resolve_wasm(discovered: &Discovered, opts: &WasmResolveOptions) -> WasmR /// `https://static.whatsapp.net:443@attacker.example/x.wasm` names `attacker.example` as /// the host a client would connect to, and must not pass as the CDN). Plaintext is refused /// so a downgraded URL can't put a fetched payload on the wire unauthenticated. -fn is_cdn_payload(url: &str) -> bool { +pub fn is_cdn_payload(url: &str) -> bool { url.starts_with("https://") && host_of(url).as_deref() == Some(ALLOWED_HOST) } @@ -458,6 +475,34 @@ mod tests { const ENDPOINT: &str = "https://web.whatsapp.com/ajax/bootloader-endpoint/"; + #[test] + fn a_rejected_binding_is_still_recorded_as_observed() { + // `by_id` keeps only what survives the wasm/CDN filters, so a caller merging a + // PRIOR handle map cannot tell an id rebound to something refused from one the + // response never mentioned. `observed_ids` is what makes that distinction — + // and building a tombstone from `by_id` instead failed for exactly the case + // the tombstone exists to cover. + let client = CannedClient::always(200, payload(&[])); + let d = discovered_with( + ENDPOINT, + &[ + ("11", "https://attacker.example/x.wasm"), + ("22", "https://static.whatsapp.net/a.wasm"), + ], + ); + let r = resolve_wasm_with(&client, &d, &WasmResolveOptions::default()); + assert_eq!( + r.by_id.keys().collect::>(), + ["22"], + "only the CDN binding survives" + ); + assert_eq!( + r.observed_ids.iter().collect::>(), + ["11", "22"], + "but both were mentioned" + ); + } + #[test] fn merges_page_and_endpoint_handles_and_filters_non_wasm() { let client = CannedClient::always( diff --git a/crates/wa-fetch/src/cache.rs b/crates/wa-fetch/src/cache.rs index f204f82c..a51900e4 100644 --- a/crates/wa-fetch/src/cache.rs +++ b/crates/wa-fetch/src/cache.rs @@ -201,11 +201,17 @@ impl BundleCache { (payloads, skipped) } - /// The recorded wasm URL → `bx` handle pairing, empty when nothing is cached (or the - /// cache predates the field). Read separately from [`Self::wasm_payloads`] because the - /// pairing is provenance metadata, not part of the payload integrity contract. - pub fn wasm_handles(&self) -> BTreeMap { + /// The recorded wasm URL → `bx` handle pairing for `wa_version`, empty when nothing is + /// cached, the cache predates the field, or the cache belongs to another release. + /// + /// Read separately from [`Self::wasm_payloads`] because the pairing is provenance + /// metadata, not part of the payload integrity contract — but it is still provenance + /// FOR ONE RELEASE. Reading the manifest unversioned let a cache left over from the + /// previous version contribute its ids to the new version's pins, which is the same + /// cross-release mixing the lock path already refuses, reached by another route. + pub fn wasm_handles(&self, wa_version: &str) -> BTreeMap { self.read_manifest() + .filter(|m| m.wa_version == wa_version) .map(|m| m.wasm_handles) .unwrap_or_default() } @@ -762,7 +768,7 @@ mod tests { .store_wasm("v", &[bundle("https://h/e.wasm", "e.wasm", b"p")], &handles) .unwrap(); - let read = cache.wasm_handles(); + let read = cache.wasm_handles("v"); assert_eq!(read.len(), 1, "only stored payloads: {read:?}"); assert_eq!( read.get("https://h/e.wasm").map(String::as_str), @@ -777,7 +783,36 @@ mod tests { &BTreeMap::new(), ) .unwrap(); - assert!(cache.wasm_handles().is_empty()); + assert!(cache.wasm_handles("v").is_empty()); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn handles_from_another_release_are_not_read() { + // The payload path already refuses a stale manifest via `usable_manifest`; the + // handle path bypassed it, so a cache left from the previous version could feed + // its ids into the new version's bootloader pins. + let dir = tmp_dir("wasm-handles-version"); + let cache = BundleCache::new(&dir); + cache + .store("old", &[bundle("https://h/a.js", "a.js", b"x")]) + .unwrap(); + let handles: BTreeMap = + [("https://h/e.wasm".to_string(), "32180".to_string())] + .into_iter() + .collect(); + cache + .store_wasm( + "old", + &[bundle("https://h/e.wasm", "e.wasm", b"p")], + &handles, + ) + .unwrap(); + assert_eq!(cache.wasm_handles("old").len(), 1, "its own release"); + assert!( + cache.wasm_handles("new").is_empty(), + "another release contributes nothing" + ); fs::remove_dir_all(&dir).ok(); } @@ -792,7 +827,7 @@ mod tests { r#"{"wa_version":"v","complete":true,"bundles":[]}"#, ) .unwrap(); - assert!(cache.wasm_handles().is_empty()); + assert!(cache.wasm_handles("v").is_empty()); assert!( cache.read_manifest().is_some(), "legacy manifest still parses" diff --git a/crates/wa-fetch/src/discover.rs b/crates/wa-fetch/src/discover.rs index 02f06930..6e9c49fc 100644 --- a/crates/wa-fetch/src/discover.rs +++ b/crates/wa-fetch/src/discover.rs @@ -199,7 +199,7 @@ pub fn discover_from_html(status: u16, html: &str, base_url: &str) -> Result bool { +pub fn is_wasm_url(url: &str) -> bool { url.split(['?', '#']) .next() .unwrap_or(url) diff --git a/crates/wa-fetch/src/lib.rs b/crates/wa-fetch/src/lib.rs index 9ebc3183..052fbd85 100644 --- a/crates/wa-fetch/src/lib.rs +++ b/crates/wa-fetch/src/lib.rs @@ -35,10 +35,10 @@ mod native; #[cfg(all(test, feature = "native"))] mod testutil; -pub use bootloader::{WasmResolution, WasmResolveOptions, resolve_wasm_with}; +pub use bootloader::{WasmResolution, WasmResolveOptions, is_cdn_payload, resolve_wasm_with}; pub use discover::{ BootloaderParams, Discovered, Sources, WA_WEB_URL, build_wa_version, discover_bundle_urls_with, - discover_from_html, + discover_from_html, is_wasm_url, }; pub use download::{Bundle, DownloadFailure, DownloadOptions, DownloadOutcome, bundle_file_name}; pub use http::{FetchError, HttpClient, HttpResponse}; diff --git a/crates/wa-ir/src/lib.rs b/crates/wa-ir/src/lib.rs index 59042e89..df938f1e 100644 --- a/crates/wa-ir/src/lib.rs +++ b/crates/wa-ir/src/lib.rs @@ -31,6 +31,20 @@ pub use abprops::*; pub use appstate::*; pub use enums::*; pub use incoming::*; +/// `true` — the serde default for a flag whose absence means "yes". +/// +/// Paired with [`is_true`] so the common case stays out of the document: a child that +/// every branch carries serializes no `required` key at all, and only the exception is +/// written down. +pub fn default_true() -> bool { + true +} + +/// Whether a flag is at its default, for `skip_serializing_if`. +pub fn is_true(b: &bool) -> bool { + *b +} + pub use iq::*; pub use mex::*; pub use notif::*; diff --git a/crates/wa-ir/src/notif.rs b/crates/wa-ir/src/notif.rs index 299bb021..e4e2919d 100644 --- a/crates/wa-ir/src/notif.rs +++ b/crates/wa-ir/src/notif.rs @@ -250,6 +250,24 @@ pub struct NotifActionChild { /// The fields read off each element. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub fields: Vec, + /// Whether EVERY branch producing this action carries the collection. + /// + /// `if (c) return {actionType: A, participants: …}; return {actionType: A}` yields two + /// legal shapes for one action, and only one of them has `participants`. Without this + /// the metadata claimed the list is always present — the same over-assertion that + /// `required` on a scalar field exists to prevent, one level up. + /// + /// Always serialized. Skipping the `true` case would leave a non-Rust consumer — + /// which is who the IR is for — unable to tell "required by default" from + /// "unspecified", and the JSON Schema cannot carry the default either. + /// + /// The JSON Schema cannot say so: `serde(default)` makes schemars drop the property + /// from `required`, and `schemars(required)` is a no-op on a non-`Option` field. A + /// document omitting it therefore validates clean, which is the ambiguity above + /// reappearing through the published contract — so `lint-ir.py` enforces the presence + /// instead. The serde default stays, so an older document still deserializes. + #[serde(default = "crate::default_true")] + pub required: bool, } /// The incoming-dispatch IR document: version stamp + the dispatcher module name + diff --git a/crates/wa-notif/src/actions.rs b/crates/wa-notif/src/actions.rs index b6568866..fab680cb 100644 --- a/crates/wa-notif/src/actions.rs +++ b/crates/wa-notif/src/actions.rs @@ -26,8 +26,40 @@ //! //! Both the case labels and the `actionType` values are `Module.CONST.MEMBER` //! references, so they are resolved against the defining module rather than guessed. +//! +//! # What this models, and what it does not +//! +//! Reading those arms means interpreting minified JavaScript, and this file has grown a +//! small scope-and-control-flow analysis to do it. It is not a general one, and the +//! boundary is worth stating rather than rediscovering: a construct outside it does not +//! produce a *wrong* action so much as a thin one, and knowing which is which is the +//! difference between trusting the output and auditing it. +//! +//! **Modelled.** Bindings in statement order (`var`, plain and comma-sequence +//! assignment, `let`/`const` confined to their block); branch scopes that clone and +//! rejoin, with a name any branch rewrote tombstoned rather than guessed; `if`/`else`, +//! bare blocks, `switch` with fall-through suffixes, `try`/`catch`/`finally` (the +//! finalizer's writes are definite, the body's are not), every loop form (`do…while` +//! runs once, so its writes are definite unless a path breaks out), and reachability — +//! nothing after a statement that exits on every path is collected. Module-local +//! helpers are inlined, bounded, including one applied to a wire read passed by value or +//! through an alias. Ambiguity is refused: two branches binding one output key to +//! different wire reads drop the key, and a table or object that cannot be read whole +//! (a spread, a computed member) is refused entirely rather than half-reported. +//! +//! **Not modelled.** Values that flow through anything other than a local binding or a +//! helper parameter — a property of an object, an array element, a closure over an outer +//! function's variable. Loop iteration: a body is analysed once, so a field whose source +//! depends on which pass wrote it is not resolved. Any arithmetic or string building on +//! a wire value. `label:`/`continue label`. Nested functions other than the helpers +//! reached explicitly, which is deliberate — descending into them is what would drag the +//! top-level child-tag dispatch into an arm's returns. +//! +//! When a construct falls outside, the arm loses the field rather than inventing one, +//! and `dropsByReason` counts what was seen and not recovered. That is the invariant to +//! preserve if this grows: a reader that guesses is worse than one that reports a gap. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use oxc_allocator::Allocator; use oxc_ast::ast::{Expression, Statement, SwitchStatement}; @@ -372,13 +404,16 @@ pub(crate) fn extract_actions( } } } + let locals: HashMap = by_name + .into_iter() + .filter_map(|(n, src)| src.map(|s| (n, s))) + .collect(); + let no_formals = HashSet::new(); let ctx = ArmCtx { consts, source: handler_slice, - locals: by_name - .into_iter() - .filter_map(|(n, src)| src.map(|s| (n, s))) - .collect(), + locals: &locals, + formals: &no_formals, }; let mut finder = SwitchFinder { ctx: &ctx, @@ -393,11 +428,36 @@ pub(crate) fn extract_actions( /// module's local helper functions, as re-parsable source (keyed by name). struct ArmCtx<'c, 'a> { consts: &'c ConstResolver<'a>, - locals: HashMap, - /// The handler module's source, so an inlined helper can be given the TEXT of the - /// arguments it was called with. The helper is re-parsed in its own allocator, so a - /// call-site AST reference cannot cross into it — its source can. + locals: &'c HashMap, + /// The source THIS context's spans index into, so an inlined helper can be given the + /// TEXT of the arguments it was called with. The helper is re-parsed in its own + /// allocator, so a call-site AST reference cannot cross into it — its source can. + /// + /// Borrowed rather than fixed to the module, because nested inlining re-parses a + /// SYNTHETIC buffer: the spans of nodes in it are offsets into that buffer, and + /// slicing the module with them lands on unrelated text — in range, so the bounds + /// check never fires, just wrong. `inline_local` rebinds this to the buffer it parsed. source: &'c str, + /// Formal parameter names of the helper currently being inlined. + /// + /// They SHADOW module helpers of the same name for the whole body, but they are not + /// in `Scope`: `apply_args` deliberately declines to substitute unless an argument + /// carries a wire read (doing it unconditionally cost 62 shape elements), so the + /// binding that would have recorded them never happens. Carried separately so the + /// shadowing survives even when the substitution is skipped. + formals: &'c HashSet, +} + +impl<'c, 'a> ArmCtx<'c, 'a> { + /// The same context reading a different source buffer, for the helper's formals. + fn nested(&self, source: &'c str, formals: &'c HashSet) -> ArmCtx<'c, 'a> { + ArmCtx { + consts: self.consts, + locals: self.locals, + source, + formals, + } + } } /// Collects `function name(…){…}` / `var name = function(…){…}` spans in a module. @@ -751,11 +811,23 @@ impl BranchFold { self.def.action_type = from.action_type; } merge_fields(&mut self.def.fields, from.fields, false, &mut self.dead); + // A collection this branch does NOT carry cannot be claimed as always present — + // the same all-branches accounting `merge_fields` does for scalars. + for existing in self.def.children.iter_mut() { + if !from.children.iter().any(|c| c.name == existing.name) { + existing.required = false; + } + } for c in from.children { match self.def.children.iter_mut().find(|x| x.name == c.name) { // Same output name and the same element: their field sets are // alternatives and merge under the rule above. Some(existing) if existing.wire_tag == c.wire_tag => { + // `from` may itself be a FOLD of several branches, and already know + // the child is absent from one of them. Merging only the fields kept + // `existing.required` true and re-asserted a presence the incoming + // side had already disproved. + existing.required &= c.required; let dead = self.dead_child_fields.entry(c.name.clone()).or_default(); merge_fields(&mut existing.fields, c.fields, false, dead); } @@ -764,7 +836,11 @@ impl BranchFold { Some(existing) => { self.dead_children.insert(existing.name.clone()); } - None => self.def.children.push(c), + // First seen in a LATER branch, so an earlier one lacked it. + None => self.def.children.push(NotifActionChild { + required: false, + ..c + }), } } // A constant only some branches stamp is not a property of the action. @@ -1361,7 +1437,7 @@ fn expand_shape( depth: u8, ) -> Vec { if depth <= MAX_INLINE_DEPTH - && let Some(src) = local_call_source(deref_ident(result, scope), ctx) + && let Some(src) = local_call_source(deref_ident(result, scope), ctx, scope) { let expanded = expand_helper(wire_tag, &src.0, &src.1, ctx, depth + 1); if !expanded.is_empty() { @@ -1391,6 +1467,28 @@ fn apply_args(fn_src: &str, arg_srcs: &[String]) -> String { } } +/// The parameter names a helper declares, whether or not the call was substituted. +fn helper_formals(e: &Expression) -> HashSet { + let params = match e { + Expression::FunctionExpression(f) => &f.params, + Expression::ArrowFunctionExpression(f) => &f.params, + _ => return HashSet::new(), + }; + let mut out = HashSet::new(); + let mut add = |p: &oxc_ast::ast::BindingPattern| { + for id in p.get_binding_identifiers() { + out.insert(id.name.to_string()); + } + }; + for p in ¶ms.items { + add(&p.pattern); + } + if let Some(rest) = ¶ms.rest { + add(&rest.rest.argument); + } + out +} + /// Split a parsed `((fn)(a, b))` into the function and a scope binding its formals to /// the call-site argument expressions. /// @@ -1458,6 +1556,12 @@ fn expand_helper( return Vec::new(); }; let (func, bound) = applied_helper(func); + // Same rebinding `inline_local` does, and for the same reason: from here the spans + // index `wrapped`, so a nested `local_call_source` slicing the module with them lands + // on unrelated text — in range, so nothing catches it. Only one of the two whole-body + // expansion paths had it, which is exactly how the first bug survived its own fix. + let formals = helper_formals(func); + let ctx = &ctx.nested(&wrapped, &formals); let mut merged: Vec = Vec::new(); for (shape, scope) in fn_result_shapes(func, &bound) { for action in expand_shape(wire_tag, shape, &scope, ctx, depth) { @@ -1519,9 +1623,9 @@ fn fold_shape<'b, 'a>( // A helper whose whole result is a repeated element (`return // child.mapChildrenWithTag("participant", …)` — where every participant // list lives). The caller names it after the key it was bound to. - if let Some(child) = mapped_child("", e, scope, ctx.consts) { + if let Some(child) = mapped_child("", e, scope, ctx) { def.children.push(child); - } else if let Some(src) = local_call_source(e, ctx) { + } else if let Some(src) = local_call_source(e, ctx, scope) { inline_local(&src.0, &src.1, def, ctx, depth); } } @@ -1533,18 +1637,49 @@ fn fold_shape<'b, 'a>( const MAX_INLINE_DEPTH: u8 = 3; /// The source of the module-local helper `e` calls, if it is one. -fn local_call_source(e: &Expression, ctx: &ArmCtx) -> Option<(String, Vec)> { +fn local_call_source<'b, 'a>( + e: &'b Expression<'a>, + ctx: &ArmCtx, + scope: &Scope<'b, 'a>, +) -> Option<(String, Vec)> { let call = as_call(e)?; let name = as_identifier(&call.callee)?; - let src = ctx.locals.get(name).cloned()?; + // The CALLEE is subject to the caller's bindings too, not only the arguments below. + // `outer(h, node){ return h(node) }` calls the `h` it was handed, so the module-level + // `h` of the same name would publish an unrelated helper's fields, or a different + // action entirely. + // + // What the caller bound is preferred, not merely refused: its text comes out of the + // source these spans index, which is why `ArmCtx` carries that buffer. Refusing + // outright loses a resolvable case — the whole field list of a callback passed as a + // literal. Only when the binding cannot be read back does it fall through to nothing. + let src = match scope.get(name) { + Some(bound) => { + let sp = oxc_span::GetSpan::span(*bound); + ctx.source + .get(sp.start as usize..sp.end as usize)? + .to_string() + } + // A formal whose argument `apply_args` declined to substitute: the name is bound + // at runtime to something this pass never saw, so the module helper is certainly + // not it. + None if ctx.formals.contains(name) => return None, + None => ctx.locals.get(name).cloned()?, + }; // The argument TEXT, so `inline_local` can bind the helper's formals. A span outside // the module source (a synthesized node) yields nothing for that position rather than // a wrong slice. + // + // Resolved through the caller's scope FIRST. The minifier hoists nearly every read + // into a local, so `var x = child.attrString("jid"); h(x)` passes the text `x` — which + // carries no wire read, means nothing inside the helper's own parse, and made the + // binding decline. Splicing what `x` is BOUND to is what makes the alias form work. let args = call .arguments .iter() .map(|a| { arg_expr(a) + .map(|e| deref_ident(e, scope)) .map(oxc_span::GetSpan::span) .and_then(|sp| ctx.source.get(sp.start as usize..sp.end as usize)) .unwrap_or("") @@ -1583,6 +1718,12 @@ fn inline_local( return; }; let (func, bound) = applied_helper(func); + // From here the spans belong to `wrapped`, not to whatever this context was reading. + // Folding with the outer source would slice the module at this buffer's offsets: in + // range and therefore unguarded, but unrelated text — which cost the field a second + // helper level down (`mk2(v){ return mk(v) }`). + let formals = helper_formals(func); + let ctx = &ctx.nested(&wrapped, &formals); // Each of the helper's result branches is folded on its own and then MERGED, not // accumulated: a helper returning `{x: …}` in one branch and `{y: …}` in another // describes two legal shapes, and combining them would make the enclosing action @@ -1720,13 +1861,13 @@ fn fold_object<'b, 'a>( write_key(def, key, KeyValue::Const(c)); continue; } - if let Some(child) = mapped_child(key, value, scope, ctx.consts) { + if let Some(child) = mapped_child(key, value, scope, ctx) { write_key(def, key, KeyValue::Child(child)); continue; } // A helper call in value position (`participants: y(chat, child, tag)`): inline // it under this key — that is where every participant list actually lives. - if let Some(src) = local_call_source(strip_guard(value), ctx) + if let Some(src) = local_call_source(strip_guard(value), ctx, scope) && depth < MAX_INLINE_DEPTH { let mut nested = empty_action(String::new()); @@ -1749,11 +1890,24 @@ fn fold_object<'b, 'a>( } // A helper whose result is a repeated element becomes this key's child list; // one that returns a flat object contributes its fields under their own names. + // + // The OUTER guard has to survive the inlining. `local_call_source` above is + // handed `strip_guard(value)`, so `participants: enabled && buildIt(node)` + // reaches the helper with the guard already gone, and the `mapped_child` inside + // sees an unguarded map — reporting a collection as always present when the + // falsy path produces none. The guard belongs to the key, not to the helper. + let outer_absent = guard_admits_absence(value); for mut c in nested.children { c.name = key.to_string(); + c.required &= !outer_absent; write_key(def, key, KeyValue::Child(c)); } - for f in nested.fields { + // Fields carry the same guard as children above. A helper whose branches + // yield a mapped collection in one and a flat object in the other contributes + // both kinds under `cond && helper(node)`, and only the collection was being + // weakened — leaving a genuinely optional field marked required. + for mut f in nested.fields { + f.required &= !outer_absent; let fkey = f.name.clone(); write_key(def, &fkey, KeyValue::Field(f)); } @@ -1789,20 +1943,41 @@ fn mapped_child<'b, 'a>( key: &str, e: &'b Expression<'a>, scope: &Scope<'b, 'a>, - consts: &ConstResolver, + ctx: &ArmCtx, ) -> Option { - let call = as_call(strip_guard(e))?; - if callee_method(call)? != wap::MAP_CHILDREN_WITH_TAG { - return None; - } + // Either branch may carry the map. `strip_guard` always follows the consequent, so + // `disabled ? 0 : node.mapChildrenWithTag(…)` — the same shape written the other way + // round — dropped the child entirely, while the absence check beside it already looks + // at both. Reversing an equivalent ternary must not erase the collection. + // Unwrapped first: `(cond ? 0 : map(…))` hides the conditional behind a paren, and + // matching on the wrapper answers "not a conditional". Third time this wrapper has + // cost this file something. + // The search filters each candidate on its own and descends into nested branches — + // see `find_mapped_call`. Filtering a combined result made the fallback fire only + // when the consequent was not a call at all, and looking one level down found an + // inner conditional rather than the map inside it. + let call = find_mapped_call(e)?; let wire_tag = arg_expr(call.arguments.first()?).and_then(as_string_lit)?; let mut fields = Vec::new(); for arg in &call.arguments { if let Some(e) = arg_expr(arg) { - collect_accessor_fields(e, scope, consts, &mut fields); + collect_accessor_fields(e, scope, ctx, &mut fields); } } Some(NotifActionChild { + // `strip_guard` above deliberately reaches THROUGH a guard to find the map call, + // so `participants: enabled && node.mapChildrenWithTag(…)` is recovered — and on + // that path there is a legal execution with no collection at all. Reporting it + // required regardless promised a consumer a collection that may never arrive, + // and `BranchFold` cannot correct it: the guard sits inside one object shape + // rather than splitting the arm into branches it could merge. + // + // What it is NOT is `!is_guarded(e)`. `read_field` runs `value_selection` before + // consulting the guard precisely because a ternary between two REAL values is a + // choice of source, not an absence — and the one live case here is exactly that: + // `requests: t.hasChildren() ? t.mapChildrenWithTag(…) : [{wid: …}]` always + // yields a collection. Only a literal absence on the far side makes it optional. + required: !guard_admits_absence(e), name: key.to_string(), wire_tag: wire_tag.to_string(), fields, @@ -1903,13 +2078,122 @@ fn value_selection<'b, 'a>( } } +/// Whether a guard around a value has an execution path that produces NO value. +/// +/// Stricter than [`is_guarded`], which asks only whether a guard is present. A ternary +/// selecting between two real values (`cond ? mapChildren(…) : [fallback]`) is guarded +/// yet always yields something, so treating every guard as optionality would publish +/// `required: false` for a collection that is in fact always there. `a && value` is the +/// opposite: the falsy path yields no value at all. +fn guard_admits_absence(e: &Expression) -> bool { + match e { + // `(cond ? map(…) : null)` is a paren wrapping the guard, and matching on the + // wrapper would answer "no guard here" for a guard that is plainly there. The + // same wrapper has cost this file a field twice before; `strip_guard` unwraps it + // for exactly this reason, so the two must agree on what they are looking at. + Expression::ParenthesizedExpression(p) => guard_admits_absence(&p.expression), + Expression::ConditionalExpression(c) => { + // Each branch is examined WHOLE, not just its stripped terminal: a guard can + // sit inside one (`cond ? enabled && map(…) : fallback`), where neither end + // is nullish yet the `!enabled` path still yields no collection. Terminates — + // every step descends into a strictly smaller expression. + is_nullish(strip_guard(&c.consequent)) + || is_nullish(strip_guard(&c.alternate)) + || guard_admits_absence(&c.consequent) + || guard_admits_absence(&c.alternate) + } + Expression::LogicalExpression(l) => { + l.operator == oxc_syntax::operator::LogicalOperator::And + } + _ => false, + } +} + +/// The `mapChildrenWithTag` call reachable from `e` through guards and ternary branches. +/// +/// Both sides, RECURSIVELY: `a ? x : (b ? y : map(…))` nests, and looking one level down +/// found the inner conditional rather than the map. Each step descends into a strictly +/// smaller node, so it terminates on the expression's own depth. +fn find_mapped_call<'b, 'a>(e: &'b Expression<'a>) -> Option<&'b oxc_ast::ast::CallExpression<'a>> { + let is_map = + |c: &&oxc_ast::ast::CallExpression| callee_method(c) == Some(wap::MAP_CHILDREN_WITH_TAG); + let e = strip_parens(e); + // A conditional is reconciled BEFORE the `strip_guard` shortcut: that shortcut follows + // the consequent, so `cond ? map("p") : map("q")` would return the first map and never + // reach the comparison below. + if !matches!(e, Expression::ConditionalExpression(_)) + && let Some(c) = as_call(strip_guard(e)).filter(is_map) + { + return Some(c); + } + match e { + Expression::ConditionalExpression(c) => { + // BOTH branches, reconciled. Taking the first one reached published one wire + // tag for a shape that produces two: `cond ? map("p", …) : map("q", …)` is + // two different collections, and picking either is a guess. Same tag on both + // sides is one collection written twice and keeps working. + match ( + find_mapped_call(&c.consequent), + find_mapped_call(&c.alternate), + ) { + (Some(a), Some(b)) => { + fn tag<'x>(c: &'x oxc_ast::ast::CallExpression) -> Option<&'x str> { + c.arguments + .first() + .and_then(arg_expr) + .and_then(as_string_lit) + } + // Same tag is not the same shape: callbacks reading `jid` and + // `lid` would publish an always-required `jid` for a branch that + // carries only `lid`. The whole call must match, arguments included. + let same_text = + |x: &oxc_ast::ast::CallExpression, y: &oxc_ast::ast::CallExpression| { + x.span == y.span + || format!("{:?}", x.arguments) == format!("{:?}", y.arguments) + }; + (tag(a) == tag(b) && same_text(a, b)).then_some(a) + } + (found, None) | (None, found) => found, + } + } + _ => None, + } +} + +/// Peel `(…)` wrappers, which the minifier and `babelHelpers` output both produce. +fn strip_parens<'b, 'a>(mut e: &'b Expression<'a>) -> &'b Expression<'a> { + while let Expression::ParenthesizedExpression(p) = e { + e = &p.expression; + } + e +} + /// Whether the expression is a literal absence — the far side of a presence guard. +/// +/// `false` counts. `enabled ? map(…) : !1` yields a boolean rather than a collection on +/// the disabled path, exactly as `enabled && map(…)` does, and only the `&&` form was +/// recognised. `!1` is how the minifier writes it. fn is_nullish(e: &Expression) -> bool { match e { Expression::NullLiteral(_) => true, + // EVERY boolean, not just `false`: `enabled ? map(…) : true` yields a boolean on + // the disabled path exactly as `: false` does. Neither is a collection. + Expression::BooleanLiteral(_) => true, Expression::Identifier(i) => i.name == "undefined", - // The minifier writes `undefined` as `void 0`. - Expression::UnaryExpression(u) => u.operator == oxc_ast::ast::UnaryOperator::Void, + Expression::NumericLiteral(_) | Expression::StringLiteral(_) => true, + // A scalar sentinel is no more a collection than `null` is: `enabled ? map(…) : 0` + // yields a number on the disabled path. Any literal that cannot be a list counts. + // A scalar sentinel is no more a collection than `null` is: `enabled ? map(…) : 0` + // yields a number on the disabled path. Any literal that cannot be a list counts. + Expression::UnaryExpression(u) => match u.operator { + // The minifier writes `undefined` as `void 0`. + oxc_ast::ast::UnaryOperator::Void => true, + // ...and as . + // `!0` and `!1` are how the minifier writes `true` and `false`. + oxc_ast::ast::UnaryOperator::LogicalNot => as_int(&u.argument).is_some(), + // ...and `false` as `!1`. + _ => false, + }, _ => false, } } @@ -2128,9 +2412,10 @@ fn is_wire_accessor(method: &str) -> bool { fn collect_accessor_fields<'b, 'a>( e: &'b Expression<'a>, outer: &Scope<'b, 'a>, - consts: &ConstResolver, + ctx: &ArmCtx, out: &mut Vec, ) { + let consts = ctx.consts; // No whole-body pre-pass. `collect_returns` installs the callback's own `var` // bindings in STATEMENT ORDER, which is the same rule it already documents for the // top level: hoisting moves the declaration, not the assignment, so snapshotting the @@ -2144,6 +2429,49 @@ fn collect_accessor_fields<'b, 'a>( e, Expression::FunctionExpression(_) | Expression::ArrowFunctionExpression(_) ) { + // A module-local callback passed BY NAME — `mapChildrenWithTag("participant", + // parseParticipant)` — is a function too. Rejecting the identifier before looking + // at its body emitted the child with an empty field list, which tells a consumer + // the element carries nothing. + // The caller's own bindings come FIRST. A parameter or local of the same name as + // a module function shadows it, and the runtime calls what the caller bound — so + // reaching straight for `ctx.locals` would publish an unrelated function's fields + // as if they were this element's. + // Only when NOTHING in scope binds the name is the module-level function the one + // that runs. A name that is bound but resolves to no expression we can follow is + // refused rather than guessed at. + let resolved = deref_ident(e, outer); + if !std::ptr::eq(resolved, e) { + // Recurse ONLY into something that is no longer an identifier. `deref_ident` + // is bounded at four hops, so on an alias cycle whose length does not divide + // four (`a → b → c → a`) it returns a *different* identifier every time — + // and recursing on that walks the cycle forever until the stack goes. An + // unresolved chain is refused, which is what the hop limit was already for. + // Recurse ONLY into something that is no longer an identifier: on a cycle + // whose length does not divide the hop limit, `deref_ident` hands back a + // different identifier every call and recursing walks it forever. + // + // But a chain ending on an identifier has a second, benign cause: a TERMINAL + // alias naming a module callback (`var cb = parseP`). Refusing those cost the + // child its whole field list. Scope still binding the name is what separates + // the two — a terminal module name is not bound by the caller, a cycle's is. + match as_identifier(resolved) { + None => collect_accessor_fields(resolved, outer, ctx, out), + Some(inner) if outer.get(inner).is_none() => { + if let Some(src) = ctx.locals.get(inner) { + collect_named_callback_fields(src, ctx, out); + } + } + Some(_) => {} + } + return; + } + if let Some(src) = as_identifier(e) + .filter(|n| outer.get(n).is_none()) + .and_then(|n| ctx.locals.get(n)) + { + collect_named_callback_fields(src, ctx, out); + } return; } // Per RETURN SHAPE, then merged — the same rule the action arms and the value-position @@ -2197,6 +2525,38 @@ fn collect_accessor_fields<'b, 'a>( } } +/// Read a NAMED mapped-child callback's per-element fields, re-parsing it in its own +/// allocator the way the value-position helpers are inlined. +/// +/// The returned fields are owned, so nothing from that parse escapes it. +fn collect_named_callback_fields(fn_src: &str, ctx: &ArmCtx, out: &mut Vec) { + let alloc = Allocator::default(); + let wrapped = format!("({fn_src})"); + let ret = wa_oxc::parse_cjs(&alloc, &wrapped); + if ret.panicked { + return; + } + let Some(func) = ret.program.body.iter().find_map(|s| match s { + Statement::ExpressionStatement(es) => Some(&es.expression), + _ => None, + }) else { + return; + }; + // The `(…)` wrapper that makes a declaration parse as an expression also hides it + // behind a `ParenthesizedExpression`, which fails the function gate downstream. + let func = match func { + Expression::ParenthesizedExpression(p) => &p.expression, + other => other, + }; + let mut fields = Vec::new(); + collect_accessor_fields(func, &Scope::new(), ctx, &mut fields); + for f in fields { + if !out.iter().any(|x| x.name == f.name) { + out.push(f); + } + } +} + /// Every `{ key: }` property reachable in one result shape. fn collect_shape_fields<'b, 'a>( shape: &'b Expression<'a>, diff --git a/crates/wa-notif/src/tests.rs b/crates/wa-notif/src/tests.rs index 4db5bd6d..2a0c8b70 100644 --- a/crates/wa-notif/src/tests.rs +++ b/crates/wa-notif/src/tests.rs @@ -598,7 +598,7 @@ __d("WAWebHandleDeviceNotification",["WADeprecatedWapParser"],(function(t,n,r,o, __d("WAWebHandleGroupNotificationConst",[],(function(t,n,r,o,a,i,l){ var cache={}; cache.GROUP_NOTIFICATION_TAG={ADD:"WRONG_add",SUBJECT:"WRONG_subject"}; - var e=Object.freeze({ADD:"add",SUBJECT:"subject",EPHEMERAL:"ephemeral",NOT_EPHEMERAL:"not_ephemeral",MODIFY:"modify",GROWTH_LOCKED:"growth_locked",INVITE:"invite",LINK:"link",ANNOUNCE:"announcement",LOCKED:"locked",REVOKE_INVITE:"revoke",DESC:"description",UNLINK:"unlink"}); + var e=Object.freeze({ADD:"add",SUBJECT:"subject",EPHEMERAL:"ephemeral",NOT_EPHEMERAL:"not_ephemeral",MODIFY:"modify",GROWTH_LOCKED:"growth_locked",INVITE:"invite",LINK:"link",ANNOUNCE:"announcement",LOCKED:"locked",REVOKE_INVITE:"revoke",DESC:"description",UNLINK:"unlink",MEMBERSHIP:"membership",GROWTH_LOCKED2:"growth_locked2"}); l.GROUP_NOTIFICATION_TAG=e; }), 1); __d("WAWebGroupApiConst",[],(function(t,n,r,o,a,i,l){ @@ -606,7 +606,7 @@ __d("WAWebGroupApiConst",[],(function(t,n,r,o,a,i,l){ l.GROUP_PARTICIPANT_TYPES=g; }), 1); __d("WAWebGroupType",[],(function(t,n,r,o,a,i,l){ - var d=Object.freeze({ADD:"add",SUBJECT:"subject",EPHEMERAL:"ephemeral",MODIFY:"modify",ANNOUNCE:"announce",RESTRICT:"restrict",REVOKE_INVITE:"revoke_invite",DESC_ADD:"desc_add",DESC_REMOVE:"desc_remove"}); + var d=Object.freeze({ADD:"add",SUBJECT:"subject",EPHEMERAL:"ephemeral",MODIFY:"modify",ANNOUNCE:"announce",RESTRICT:"restrict",REVOKE_INVITE:"revoke_invite",DESC_ADD:"desc_add",DESC_REMOVE:"desc_remove",MEMBERSHIP:"membership"}); l.GROUP_ACTIONS=d; }), 1); __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGroupType"],(function(t,n,r,o,a,i,l){ @@ -621,9 +621,24 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr if (e.hasChild("b")) return {actionType:o("WAWebGroupType").GROUP_ACTIONS.ANNOUNCE, rows:e.mapChildrenWithTag("row", function(x){ return {v:x.attrString("q")}; })}; return {actionType:o("WAWebGroupType").GROUP_ACTIONS.ANNOUNCE, rows:e.mapChildrenWithTag("row", function(x){ return {v:x.attrString("p")}; })}; } + function shadowedCb(x){ return {wrong:x.attrString("wrong")}; } + function hh(e){ return {wrongHelper:e.attrString("wrong")}; } + function callsShadowed(e,hh){ return hh(e); } + function parseP(x){ return {aliased:x.attrString("via_alias")}; } + function hx(e){ return {wrongFormal:e.attrString("nope")}; } + function realCb(e){ return {rightFormal:e.attrString("ok2")}; } + function other(e){ return null; } + function outerH(node, hx){ return hx(node); } + function mk(v){ return {chained:v}; } + function mk2(v){ return mk(v); } + function mixedKids(e){ return {plainField:e.attrString("plain"), kids:e.mapChildrenWithTag("mixed_user", function(x){ return {id:x.attrUserJid("jid")}; })}; } + function helperKids(e){ return e.mapChildrenWithTag("helper_user", function(x){ return {id:x.attrUserJid("jid")}; }); } + function withShadow(e,shadowedCb){ return e.mapChildrenWithTag("shadow_user", shadowedCb); } function y(e,t){ return t.mapChildrenWithTag("participant", function(p){ return { id: p.attrUserJid("jid"), displayName: p.maybeAttrString("display_name"), kind: p.maybeAttrEnum("type", o("WAWebGroupApiConst").GROUP_PARTICIPANT_TYPES) }; }); } + function innerWhole(e,v){ return {actionType:o("WAWebGroupType").GROUP_ACTIONS.RESTRICT, deepWhole:v}; } + function outerWhole(e,v){ return innerWhole(e,v); } function I(e){ var t=e.attrString("unlink_type"); if(t==="parent_group") return {actionType:o("WAWebGroupType").GROUP_ACTIONS.DESC_REMOVE, descId:e.attrString("id")}; @@ -667,8 +682,37 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr var n; return {actionType:o("WAWebGroupType").GROUP_ACTIONS.RESTRICT, value:!0, threshold:(n=t.maybeAttrString("threshold"))!=null?n:void 0, seeded:thr}; } + case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.GROWTH_LOCKED2: + return outerWhole(t, t.attrString("deep_whole")); case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.UNLINK: return I(t); + case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.MEMBERSHIP: { + var cycA=cycB, cycB=cycC, cycC=cycA; + var cbAlias=parseP; + return {actionType:o("WAWebGroupType").GROUP_ACTIONS.MEMBERSHIP, + picked: t.hasChildren() ? t.mapChildrenWithTag("picked_user", function(x){ return {id:x.attrUserJid("jid")}; }) : [{id:"fallback"}], + nulled: t.hasChild("opt") ? t.mapChildrenWithTag("nulled_user", function(x){ return {id:x.attrUserJid("jid")}; }) : null, + anded: t.hasChild("flag") && t.mapChildrenWithTag("anded_user", function(x){ return {id:x.attrUserJid("jid")}; }), + parend: (t.hasChild("p") ? t.mapChildrenWithTag("parend_user", function(x){ return {id:x.attrUserJid("jid")}; }) : null), + shadowed: withShadow(t, function(z){ return {right:z.attrString("right")}; }), + viaHelper: t.hasChild("h") && helperKids(t), + cyclic: t.mapChildrenWithTag("cyclic_user", cycA), + twoDeep: mk2(t.attrString("deep_jid")), + viaShadowedCallee: callsShadowed(t, function(z){ return {rightHelper:z.attrString("ok")}; }), + mixed: t.hasChild("mx") && mixedKids(t), + aliasedCb: t.mapChildrenWithTag("alias_user", cbAlias), + nestedGuard: t.hasChild("ng") ? (t.hasChild("en") && t.mapChildrenWithTag("nested_user", function(x){ return {id:x.attrUserJid("jid")}; })) : [{id:"fb"}], + fromFormal: outerH(t, realCb), + falsyFallback: t.hasChild("ff") ? t.mapChildrenWithTag("falsy_user", function(x){ return {id:x.attrUserJid("jid")}; }) : !1, + bareFalse: t.hasChild("bf") ? t.mapChildrenWithTag("bare_user", function(x){ return {id:x.attrUserJid("jid")}; }) : false, + scalarFb: t.hasChild("sf") ? t.mapChildrenWithTag("scalar_user", function(x){ return {id:x.attrUserJid("jid")}; }) : 0, + reversed_: t.hasChild("rv") ? 0 : t.mapChildrenWithTag("reversed_user", function(x){ return {id:x.attrUserJid("jid")}; }), + parenRev: (t.hasChild("pr") ? 0 : t.mapChildrenWithTag("paren_rev_user", function(x){ return {id:x.attrUserJid("jid")}; })), + callRev: t.hasChild("cr") ? other(t) : t.mapChildrenWithTag("call_rev_user", function(x){ return {id:x.attrUserJid("jid")}; }), + nestedRev: t.hasChild("n1") ? other(t) : (t.hasChild("n2") ? other(t) : t.mapChildrenWithTag("nested_rev_user", function(x){ return {id:x.attrUserJid("jid")}; })), + twoMaps: t.hasChild("tm") ? t.mapChildrenWithTag("tm_p", function(x){ return {id:x.attrUserJid("jid")}; }) : t.mapChildrenWithTag("tm_q", function(x){ return {id:x.attrUserJid("jid")}; }), + sameTagDiffCb: t.hasChild("st") ? t.mapChildrenWithTag("same_tag", function(x){ return {id:x.attrUserJid("jid")}; }) : t.mapChildrenWithTag("same_tag", function(x){ return {id:x.attrUserJid("lid")}; })}; + } case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.REVOKE_INVITE: return {actionType:o("WAWebGroupType").GROUP_ACTIONS.REVOKE_INVITE, participants:t.mapChildrenWithTag("participant", function(p){ return {id:p.attrUserJid("jid"), expiration:p.attrInt("expiration")}; }), owners:t.mapChildrenWithTag("owner", p=>o("WAWebJidToWid").userJidToUserWid(p.maybeAttrPhoneUserJid("phone_number")))}; case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.DESC: @@ -791,6 +835,209 @@ fn group_action_arms_recover_fields_children_and_branches() { assert_eq!(body.wire_name, "body"); } +#[test] +fn a_mapped_child_is_optional_only_when_the_guard_admits_absence() { + // `strip_guard` reaches through a guard to find the map call, so all three arrive. + // Whether the collection can be MISSING is a different question from whether a guard + // is present, and answering it with `is_guarded` alone gets the first case wrong: + // WA's live `created_membership_requests` reads + // `t.hasChildren() ? t.mapChildrenWithTag(…) : [{wid: …}]`, which always yields a + // collection. Only a literal absence on the far side — or `&&`, whose falsy path + // yields no value at all — makes it optional. + let ir = extract_notif(GROUP_ACTIONS_BUNDLE, "2.3000.test"); + let arm = group_actions(&ir) + .into_iter() + .find(|a| a.wire_tag == "membership") + .expect("membership arm"); + let child = |name: &str| { + arm.children + .iter() + .find(|c| c.name == name) + .unwrap_or_else(|| panic!("no child {name}")) + }; + assert!( + child("picked").required, + "a ternary between two real values always yields a collection" + ); + assert!( + !child("nulled").required, + "`cond ? map(…) : null` may be absent" + ); + assert!(!child("anded").required, "`cond && map(…)` may be absent"); + assert!( + !child("parend").required, + "a paren around the guard must not hide it" + ); + // A callback name that a caller binding SHADOWS must resolve to what the caller + // passed, not to the module function of the same name. Publishing the module one + // would report an unrelated element's fields as this child's. + let sh = child("shadowed"); + assert_eq!(sh.wire_tag, "shadow_user"); + let names: Vec<&str> = sh.fields.iter().map(|f| f.name.as_str()).collect(); + assert_eq!( + names, + ["right"], + "the shadowing argument wins, not `shadowedCb`" + ); + + // A guard around a value-position HELPER belongs to the key, not to the helper: the + // inliner is handed the stripped expression, so the map inside looks unguarded. + assert!( + !child("viaHelper").required, + "`cond && helper(node)` may produce no collection" + ); + assert_eq!(child("viaHelper").wire_tag, "helper_user"); + + // An alias CYCLE (`cycA → cycB → cycC → cycA`) must terminate. `deref_ident` caps at + // four hops, so on a 3-cycle it hands back a different identifier every time, and + // recursing on that walked the cycle until the stack went — reaching this assertion + // at all is most of the test. + let cyc = child("cyclic"); + assert_eq!(cyc.wire_tag, "cyclic_user"); + assert!( + cyc.fields.is_empty(), + "an unresolved callback contributes no fields" + ); + + // A value threaded through TWO helpers: the second inlining re-parses a synthetic + // buffer, so its spans index that buffer — slicing the module with them lands on + // unrelated text, in range and therefore unguarded, and the field vanishes. + let deep = arm + .fields + .iter() + .find(|f| f.name == "chained") + .expect("a field threaded through two helpers survives"); + assert_eq!(deep.wire_name, "deep_jid"); + + // A helper CALLEE the caller binds is the one that runs. Inlining the module-level + // function of the same name would publish an unrelated helper's fields. + let names: Vec<&str> = arm.fields.iter().map(|f| f.name.as_str()).collect(); + assert!( + !names.contains(&"wrongHelper"), + "the module-level `hh` must not be inlined over the bound one: {names:?}" + ); + // ...and the one that IS bound must still come through. Asserting only the absence + // would pass just as happily if the resolution silently produced nothing. + assert!( + names.contains(&"rightHelper"), + "the callback the caller bound is the one that runs: {names:?}" + ); + + // A guarded value-position helper whose branches yield BOTH a collection and a flat + // field must weaken both. Only the collection was being weakened. + let plain = arm + .fields + .iter() + .find(|f| f.name == "plainField") + .expect("the helper's flat branch contributes a field"); + assert!( + !plain.required, + "`cond && helper(node)` makes the helper's fields optional too, not only its children" + ); + + // A TERMINAL alias (`var cbAlias = parseP`) names a module callback and must resolve. + // The cycle guard above refuses any chain ending on an identifier, which threw this + // away too — the child shipped with an empty field list. + let aliased = child("aliasedCb"); + assert_eq!(aliased.wire_tag, "alias_user"); + assert_eq!( + aliased + .fields + .iter() + .map(|f| f.name.as_str()) + .collect::>(), + ["aliased"], + "a terminal alias resolves through the module callbacks" + ); + + // A guard NESTED inside a ternary branch: neither end is nullish, but the `&&` path + // still yields no collection. + assert!( + !child("nestedGuard").required, + "a guard inside a branch counts, not only a nullish terminal" + ); + + // A helper FORMAL shadows a module helper of the same name even when `apply_args` + // declines to substitute — the formal never reaches `Scope` on that path. + let names2: Vec<&str> = arm.fields.iter().map(|f| f.name.as_str()).collect(); + assert!( + !names2.contains(&"wrongFormal"), + "the module-level `hx` must not win over the formal: {names2:?}" + ); + + // `: !1` is how the minifier writes `false`, and a boolean is no more a collection + // than `null` is — the `&&` form of the same falsy path was already handled. + assert!( + !child("falsyFallback").required, + "a falsy literal fallback is an absent collection" + ); + // Both spellings: `!1` is a unary expression, `false` a boolean literal, and they are + // recognised by different arms — a test hitting only one leaves the other unguarded. + assert!( + !child("bareFalse").required, + "the unminified `false` is the same absence" + ); + // A scalar sentinel is no more a collection than `null` is. + assert!( + !child("scalarFb").required, + "`: 0` on the far side is an absent collection too" + ); + // The same ternary written the other way round must yield the same child, not none: + // `strip_guard` follows the consequent, so the map in the ALTERNATE was invisible. + let rev = child("reversed_"); + assert_eq!(rev.wire_tag, "reversed_user"); + assert!(!rev.required, "the scalar consequent is the absent path"); + // ...and a paren around it must not hide the conditional from the branch search. + // A CALL consequent is the case the `0` fixtures could not reach: filtering the + // combined result made the fallback fire only when the consequent was not a call. + assert_eq!(child("callRev").wire_tag, "call_rev_user"); + // ...and nested one level deeper: looking at a single alternate found the inner + // conditional, not the map inside it. + assert_eq!(child("nestedRev").wire_tag, "nested_rev_user"); + // Two DIFFERENT collections, one per branch: publishing either tag would describe a + // shape the runtime produces only half the time, so the child is refused instead. + assert!( + !arm.children.iter().any(|c| c.name == "twoMaps"), + "conflicting tags in the two branches must not resolve to one of them" + ); + // Same tag, DIFFERENT callbacks: publishing one would assert `jid` for a branch that + // reads `lid`. Equal tag is not equal shape. + assert!( + !arm.children.iter().any(|c| c.name == "sameTagDiffCb"), + "same tag with different callback reads is still ambiguous" + ); + + let paren_rev = child("parenRev"); + assert_eq!(paren_rev.wire_tag, "paren_rev_user"); + assert!( + !paren_rev.required, + "the paren must not cost the absence either — asserting only the tag would pass \ + with the collection wrongly marked always-present" + ); + + // The guard must not cost the child's contents. + assert_eq!(child("anded").wire_tag, "anded_user"); + assert_eq!(child("anded").fields.len(), 1); +} + +#[test] +fn a_two_level_whole_result_helper_keeps_its_fields() { + // `expand_helper` re-parses into a synthetic buffer just as `inline_local` does, but + // only one of the two rebound the context to it — so at the second level the nested + // `local_call_source` sliced the module source with this buffer's offsets: in range, + // unguarded, and unrelated. + let ir = extract_notif(GROUP_ACTIONS_BUNDLE, "2.3000.test"); + let arm = group_actions(&ir) + .into_iter() + .find(|a| a.wire_tag == "growth_locked2") + .expect("the delegating arm"); + let names: Vec<&str> = arm.fields.iter().map(|f| f.name.as_str()).collect(); + assert!( + names.contains(&"deepWhole"), + "the field survives two whole-result helpers: {names:?}" + ); +} + #[test] fn handler_without_a_const_keyed_switch_has_no_actions() { // Most handlers forward straight to a `WADeprecatedWapParser` and carry no action @@ -1229,7 +1476,7 @@ __d("WAWebCommsHandleLoggedInStanza",["WAWebHandleGroupNotification"],function(g }); }; }, 1); __d("WAWebHandleGroupNotificationConst",[],(function(t,n,r,o,a,i,l){ - l.GROUP_NOTIFICATION_TAG=Object.freeze({EVICT:"evict",COND:"cond",REBIND:"rebind",PICK:"pick",BARE:"bare",HELPER:"helper",ESCAPE:"escape",TAIL:"tail",JOIN:"join",SEQ:"seq",SUFFIX:"suffix",AFTER:"after",TRY:"try_tag",FIN:"fin",FINCOND:"fincond",FINTHROW:"finthrow",MULTI:"multi",MULTI3:"multi3",DEAD:"dead",LIT:"lit",LOOP:"loop_tag",FINTRY:"fintry",PARAM:"param",BLK:"blk",EXH:"exh",NFT:"nft",LATE:"late",DOW:"dow",SHADOW:"shadow",CATCHP:"catchp",DOWX:"dowx",SAME:"same",NEST:"nest",DOWB:"dowb",PVAL:"pval",SPREAD:"spread",DOWBRK:"dowbrk",FINW:"finw",FORI:"fori",CONDW:"condw",SPRD:"sprd"}); + l.GROUP_NOTIFICATION_TAG=Object.freeze({EVICT:"evict",COND:"cond",REBIND:"rebind",PICK:"pick",BARE:"bare",HELPER:"helper",ESCAPE:"escape",TAIL:"tail",JOIN:"join",SEQ:"seq",SUFFIX:"suffix",AFTER:"after",TRY:"try_tag",FIN:"fin",FINCOND:"fincond",FINTHROW:"finthrow",MULTI:"multi",MULTI3:"multi3",DEAD:"dead",LIT:"lit",LOOP:"loop_tag",FINTRY:"fintry",PARAM:"param",BLK:"blk",EXH:"exh",NFT:"nft",LATE:"late",DOW:"dow",SHADOW:"shadow",CATCHP:"catchp",DOWX:"dowx",SAME:"same",NEST:"nest",DOWB:"dowb",PVAL:"pval",SPREAD:"spread",DOWBRK:"dowbrk",FINW:"finw",FORI:"fori",CONDW:"condw",SPRD:"sprd",ALIAS:"alias",NAMEDCB:"namedcb",OPTCH:"optch"}); }), 1); __d("WAWebGroupType",[],(function(t,n,r,o,a,i,l){ l.GROUP_ACTIONS=Object.freeze({FIRST:"first",SECOND:"second",THIRD:"third"}); @@ -1238,6 +1485,7 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr function hlp(e){ return {actionType:o("WAWebGroupType").GROUP_ACTIONS.SECOND, extra:e.attrString("extra")}; } function norm(v){ return v == null ? null : v; } function mk(v){ return {actionType:o("WAWebGroupType").GROUP_ACTIONS.THIRD, id:v}; } + function parseP(p){ return {id:p.attrString("jid"), nick:p.maybeAttrString("nick")}; } function h(e){ var x=e.mapChildrenWithTag("child", function(t){ switch (t.tag) { @@ -1368,6 +1616,15 @@ __d("WAWebHandleGroupNotification",["WAWebHandleGroupNotificationConst","WAWebGr } case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.SPRD: return babelHelpers.extends({actionType:o("WAWebGroupType").GROUP_ACTIONS.FIRST}, {id:t.attrString("jid"), ...base}); + case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.ALIAS: { + var av = t.attrString("jid"); + return mk(av); + } + case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.NAMEDCB: + return {actionType:o("WAWebGroupType").GROUP_ACTIONS.FIRST, who:t.mapChildrenWithTag("participant", parseP)}; + case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.OPTCH: + if (t.hasChild("full")) return {actionType:o("WAWebGroupType").GROUP_ACTIONS.SECOND, who:t.mapChildrenWithTag("p", function(p){ return {id:p.attrString("jid")}; })}; + return {actionType:o("WAWebGroupType").GROUP_ACTIONS.SECOND, other:t.attrString("o")}; case o("WAWebHandleGroupNotificationConst").GROUP_NOTIFICATION_TAG.SUFFIX: switch (t.attrString("k")) { case "a": t.attrString("setup"); @@ -2079,3 +2336,53 @@ fn an_action_object_with_a_spread_is_refused() { a.fields ); } + +#[test] +fn a_helper_given_an_aliased_read_binds_its_parameter() { + // The minifier hoists nearly every read into a local, so `var av = attrString("jid"); + // mk(av)` passes the text `av` — no wire read in it, and meaningless inside the + // helper's own parse. Resolving the argument through the caller's scope first is what + // makes the alias form work; deciding on the raw text declined it. + let ir = extract_notif(GROUP_ACTIONS_EDGE_BUNDLE, "2.3000.test"); + let a = edge_action(&ir, "alias"); + assert_eq!(a.action_type.as_deref(), Some("third")); + let f = a + .fields + .iter() + .find(|f| f.name == "id") + .expect("the id field"); + assert_eq!(f.wire_name, "jid"); +} + +#[test] +fn a_named_mapped_child_callback_is_read() { + // `mapChildrenWithTag("participant", parseP)` — a module-local callback by NAME is a + // function too. Rejecting the identifier before looking at its body emitted the child + // with no fields, telling a consumer the element carries nothing. + let ir = extract_notif(GROUP_ACTIONS_EDGE_BUNDLE, "2.3000.test"); + let a = edge_action(&ir, "namedcb"); + let child = a + .children + .iter() + .find(|c| c.name == "who") + .expect("the child"); + let names: Vec<&str> = child.fields.iter().map(|f| f.name.as_str()).collect(); + assert!( + names.contains(&"id") && names.contains(&"nick"), + "fields: {names:?}" + ); +} + +#[test] +fn a_child_only_one_branch_carries_is_optional() { + // Two branches, one action, and only one carries the collection. Claiming it is always + // present is the same over-assertion `required` prevents for a scalar, one level up. + let ir = extract_notif(GROUP_ACTIONS_EDGE_BUNDLE, "2.3000.test"); + let a = edge_action(&ir, "optch"); + let child = a + .children + .iter() + .find(|c| c.name == "who") + .expect("the child survives"); + assert!(!child.required, "a branch without it makes it optional"); +} diff --git a/crates/whatspec/src/lock.rs b/crates/whatspec/src/lock.rs index 140afa8e..7b72da53 100644 --- a/crates/whatspec/src/lock.rs +++ b/crates/whatspec/src/lock.rs @@ -12,6 +12,8 @@ //! of bundle contents, not their filenames or order. [`set_hash`] fingerprints that //! multiset in one line; [`BundleLock`] carries the full per-bundle detail. +use std::collections::BTreeMap; + use serde::{Deserialize, Serialize}; /// One bundle's identity, as collected during a generation run. The `url` is present @@ -144,6 +146,40 @@ pub struct WasmLockEntry { pub size: u64, } +/// The bootloader extraction, pinned. +/// +/// The JS never carries a wasm URL: it asks the bootloader for a numeric `bx` id, and only +/// the page's resource map turns that id into something fetchable. Nothing recorded that +/// map, so a change in the bootloader's shape showed up only as a wasm count that quietly +/// stopped growing — and `wasmResources` is deliberately unguarded, because most handles +/// address theme images that come and go. +/// +/// What is pinned is the EXTRACTION, not the page. The HTML carries nonces and timestamps +/// and is byte-unstable by construction; hashing it would fail the determinism gate for +/// reasons that have nothing to do with the protocol. The resolved id→URI map is stable +/// for a given release, so it can be diffed across releases and read on its own. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BootloaderPins { + /// Handles the page inlined, before any request to the bootloader endpoint. + pub handles_from_page: usize, + /// Every `bx` id that resolved to a wasm URL, page and endpoint together. + pub wasm_handles: BTreeMap, + /// Endpoint requests sent, and how many came back unusable. A run that suddenly + /// needs more rounds, or starts failing, is the early signal that the endpoint + /// contract moved. + pub requests: usize, + pub failed_requests: usize, + /// How many ways resolution came up short, INCLUDING the ones that never reached a + /// request — a component list capped by `max_components`, a page that deferred + /// nothing, endpoint parameters the page did not ship. `failed_requests` counts only + /// requests that failed, so a capped run whose requests all succeeded pinned zeroes + /// across the board and, with the handle map accumulating, left no diff at all to say + /// the extraction had been incomplete. + #[serde(default)] + pub degradations: usize, +} + /// The wasm lockfile (`generated/wasm.lock.json`) — what a fetch run resolved and stored. /// /// Deliberately **separate** from [`BundleLock`]: @@ -165,18 +201,30 @@ pub struct WasmLock { pub wasm_set_hash: String, pub wasm_count: usize, /// Every payload, sorted by `(sha256, url)` for a deterministic, diffable file. + /// What the page's bootloader actually yielded this run — see [`BootloaderPins`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bootloader: Option, pub wasm: Vec, } impl WasmLock { - /// Build a lock from a fetch run's resolved payloads. - pub fn new(wa_version: &str, mut wasm: Vec) -> Self { + /// Build a lock from a fetch run's resolved payloads and what the bootloader yielded. + /// + /// `bootloader` is `None` only for a path that never asked it anything; a local + /// `--bundles` regen does not reach here at all, so a recorded map is never + /// overwritten with nothing. + pub fn with_bootloader( + wa_version: &str, + mut wasm: Vec, + bootloader: Option, + ) -> Self { wasm.sort_by(|a, b| a.sha256.cmp(&b.sha256).then_with(|| a.url.cmp(&b.url))); let ids: Vec = wasm.iter().map(WasmLockEntry::as_bundle_id).collect(); Self { wa_version: wa_version.to_string(), wasm_set_hash: set_hash(&ids), wasm_count: wasm.len(), + bootloader, wasm, } } @@ -333,12 +381,13 @@ mod tests { #[test] fn wasm_lock_sorts_fingerprints_and_round_trips() { - let lock = WasmLock::new( + let lock = WasmLock::with_bootloader( "2.3000.1", vec![ wasm_entry("ff", 9, "https://s/y/voip.wasm", Some("32180")), wasm_entry("aa", 1, "https://s/x/liboqs.wasm", None), ], + None, ); assert_eq!(lock.wasm[0].sha256, "aa", "sorted by content hash"); assert_eq!(lock.wasm[0].file_name, "liboqs.wasm"); @@ -364,19 +413,24 @@ mod tests { // (the published JS archive name and the reproducibility gate depend on it). let bundles = vec![id("aa", 1, Some("https://s/a.js"))]; let js = BundleLock::new("v", bundles.clone()); - let wasm = WasmLock::new("v", vec![wasm_entry("bb", 2, "https://s/w.wasm", None)]); + let wasm = WasmLock::with_bootloader( + "v", + vec![wasm_entry("bb", 2, "https://s/w.wasm", None)], + None, + ); assert_ne!(js.set_hash, wasm.wasm_set_hash); assert_eq!(js.set_hash, BundleLock::new("v", bundles).set_hash); } #[test] fn wasm_self_consistency_catches_tampering() { - let lock = WasmLock::new( + let lock = WasmLock::with_bootloader( "v", vec![ wasm_entry("aa", 1, "https://s/a.wasm", None), wasm_entry("bb", 2, "https://s/b.wasm", None), ], + None, ); let mut tampered = lock.clone(); tampered.wasm[0].sha256 = "cc".to_string(); @@ -395,4 +449,28 @@ mod tests { .contains("wasmCount") ); } + #[test] + fn the_bootloader_map_survives_a_lock_round_trip() { + // The id→URI map is the only thing that turns the numeric handle the JS asks for + // into something fetchable, and nothing recorded it — a change in the bootloader + // showed up only as a wasm count that quietly stopped growing. + let pins = BootloaderPins { + handles_from_page: 12, + wasm_handles: BTreeMap::from([("30933".into(), "https://s/a.wasm".into())]), + requests: 4, + failed_requests: 1, + degradations: 2, + }; + let lock = WasmLock::with_bootloader( + "2.3000.TEST", + vec![wasm_entry("aa", 1, "https://s/a.wasm", Some("30933"))], + Some(pins.clone()), + ); + let back: WasmLock = serde_json::from_str(&lock.to_pretty_json()).expect("round trip"); + assert_eq!(back.bootloader.as_ref(), Some(&pins)); + // And a run that never asked the bootloader writes no key at all, so an offline + // regen cannot be mistaken for "the map is empty now". + let bare = WasmLock::with_bootloader("2.3000.TEST", Vec::new(), None); + assert!(!bare.to_pretty_json().contains("bootloader")); + } } diff --git a/crates/whatspec/src/main.rs b/crates/whatspec/src/main.rs index 73cccc69..7dc57267 100644 --- a/crates/whatspec/src/main.rs +++ b/crates/whatspec/src/main.rs @@ -2,7 +2,10 @@ //! versioned artifacts (IQ specs, WAProto.proto, mex operations, appstate //! schemas) to disk, ready to be committed — locally or from CI. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; +// Only the fetch path builds the bootloader pin map from it. +#[cfg(feature = "fetch")] +use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; @@ -277,6 +280,8 @@ fn update(args: &[String]) -> Result<()> { source, bundles, wasm, + boot_pins, + live_handle_ids, authoritative, } = load_source(&opts)?; eprintln!("WhatsApp version: {wa_version}"); @@ -349,12 +354,33 @@ fn update(args: &[String]) -> Result<()> { // `update` (no `--wasm`) must leave a previously committed wasm lock alone rather // than truncate it to an empty set it never looked for. if authoritative && opts.wasm && wasm.is_empty() { - warn_if_wasm_lock_is_now_stale(&opts.out.join(WASM_LOCK_NAME), &wa_version); + let lock_path = opts.out.join(WASM_LOCK_NAME); + warn_if_wasm_lock_is_now_stale(&lock_path, &wa_version); + // A blocked or changed bootloader is EXACTLY the state the pins were added to + // record, and it is also the state that yields no payloads — so dropping them + // here would lose the diagnostic precisely when it matters. The recorded payload + // set is preserved (that is what the emptiness guard protects); only the + // bootloader section is rewritten. + // Gated with the function it calls: without `fetch` there is no bootloader to + // have asked, and `boot_pins` is always `None`. The call site was NOT gated when + // the function was, so the no-default-features build stopped compiling — a + // configuration `cargo test --workspace` never exercises. + #[cfg(feature = "fetch")] + if let Some(pins) = &boot_pins + && let Err(e) = update_lock_bootloader(&lock_path, pins, &wa_version, &live_handle_ids) + { + eprintln!(" wasm lock not updated with the bootloader map: {e:#}"); + } } if authoritative && !wasm.is_empty() { let lock_path = opts.out.join(WASM_LOCK_NAME); warn_if_wasm_shrank(&lock_path, &wasm); - let lock = WasmLock::new(&wa_version, wasm); + // Accumulated here too, not only on the empty path. A run that resolves a SMALLER + // endpoint subset still succeeds, and replacing the section with its own sample + // would delete ids an earlier run resolved — the same shrink, just reached by a + // productive run instead of a blocked one. + let boot_pins = pins_with_prior(&lock_path, boot_pins, &wa_version, &live_handle_ids); + let lock = WasmLock::with_bootloader(&wa_version, wasm, boot_pins); fs::write(&lock_path, lock.to_pretty_json()) .with_context(|| format!("write {}", lock_path.display()))?; eprintln!( @@ -934,6 +960,14 @@ struct Loaded { /// for on the fetch path. Never part of `source`: these are binaries, and the /// extractors parse `source` as JavaScript. wasm: Vec, + /// What the bootloader yielded this run, when the fetch path ran — see + /// [`lock::BootloaderPins`]. `None` for a local `--bundles` regen, which never + /// asks the bootloader anything and must not clobber a recorded map with nothing. + boot_pins: Option, + /// `bx` ids this run actually observed live, whether or not their binding survived + /// the CDN filter. Absence from the pins then means "rejected", not "never seen", and + /// the prior lock must not restore it. + live_handle_ids: BTreeSet, /// `true` only for the fetch path, which knows every bundle's origin URL and is /// therefore the *authoritative* source of a full lockfile. The offline `--bundles` /// path fingerprints the same inputs (for `--check`) but never rewrites the lock, @@ -959,6 +993,8 @@ fn load_source(opts: &Options) -> Result { // directory of `.js` files, so it stays empty (and the committed wasm // lock is left untouched — see `authoritative`). wasm: Vec::new(), + boot_pins: None, + live_handle_ids: BTreeSet::new(), authoritative: false, }) } @@ -1072,12 +1108,15 @@ fn fetch_source(opts: &Options) -> Result { bundles.len() ); maybe_save_bundles(opts, &bundles)?; - let wasm = load_wasm(opts, &discovered, Some(remote_version.as_str()))?; + let (wasm, boot_pins, live_handle_ids) = + load_wasm(opts, &discovered, Some(remote_version.as_str()))?; return Ok(Loaded { wa_version: version, source: concat_bundles(&bundles), bundles: bundle_ids(&bundles), wasm, + boot_pins, + live_handle_ids, authoritative: true, }); } @@ -1122,12 +1161,15 @@ fn fetch_source(opts: &Options) -> Result { // must never be able to cost the (expensive) JS download a retry. // The discovered version keys the cache; it does not gate the fetch. A run stamped // with `--wa-version` over an unreadable page still collects its payloads. - let wasm = load_wasm(opts, &discovered, discovered.wa_version.as_deref())?; + let (wasm, boot_pins, live_handle_ids) = + load_wasm(opts, &discovered, discovered.wa_version.as_deref())?; Ok(Loaded { wa_version: version, source: concat_bundles(&outcome.bundles), bundles: bundle_ids(&outcome.bundles), wasm, + boot_pins, + live_handle_ids, authoritative: true, }) } @@ -1179,9 +1221,13 @@ fn load_wasm( opts: &Options, discovered: &wa_fetch::Discovered, remote_version: Option<&str>, -) -> Result> { +) -> Result<( + Vec, + Option, + BTreeSet, +)> { if !opts.wasm { - return Ok(Vec::new()); + return Ok((Vec::new(), None, BTreeSet::new())); } // The cache is keyed by the *discovered* version. Without one it can't be used at all @@ -1285,21 +1331,61 @@ fn load_wasm( String::new() } ); - if payloads.is_empty() { - // Still sweep: an explicit `--wasm-out` that produced nothing must not leave the - // previous run's payloads behind, or a runner would consume a set no lock - // describes. Not an error — a blocked resolution must not fail a spec update. - maybe_save_wasm(opts, &payloads)?; - return Ok(Vec::new()); - } - // Handles: what this run resolved, plus what the cache recorded for payloads it is // carrying over (their `bx` ids were learned in the run that downloaded them). + // Built before the empty-payload exit so the pins describe the accumulated map even + // when this particular run resolved nothing. let mut handles = invert(&discovered.bx_data); if let Some(cache) = &cache { - handles.extend(cache.wasm_handles()); + // Version-scoped: a cache for the previous release must not contribute its ids + // to this one's pins. `wasm_payloads` below already refuses such a manifest, so + // reading the handles unversioned mixed releases through the one door left open. + handles.extend(cache.wasm_handles(remote_version.unwrap_or_default())); } handles.extend(resolution.handle_by_url()); + // A handle the live response REBOUND now names a different URL, and the cache's old + // `A -> 11` would otherwise sit beside the new `B -> 11`: `wasm_entries` would stamp + // `bxId: 11` on both payloads while the pins say 11 resolves to B — a lock that + // contradicts itself. The live map wins, so any other URL claiming a live id goes. + drop_stale_aliases(&mut handles, &resolution.by_id, &resolution.observed_ids); + + // The PINS are built from the id → URL direction instead, because `handles` is + // URL-keyed and therefore already lossy: `invert`/`handle_by_url` keep the lowest id + // where several `bx` ids alias one wasm URL, and reversing that back recovers only + // that one. `wasm_handles` promises every id that resolved, and a JS reference using + // one of the dropped aliases would find nothing in the map. (The cache's own record + // is URL-keyed too, so its aliases were already lost when it was written; the two + // authoritative sources are not.) + let mut ids: BTreeMap = discovered.bx_data.clone(); + ids.extend(resolution.by_id.clone()); + if let Some(cache) = &cache { + for (url, id) in cache.wasm_handles(remote_version.unwrap_or_default()) { + // Skipped when the run SAW this id: `by_id` above already inserted the live + // binding if it survived, and if it did not, the cache's URL is the one the + // response replaced. `live_handle_ids` can only suppress the prior-LOCK + // merge, so an entry inserted here would reach the pins unchallenged. + if resolution.observed_ids.contains(&id) { + continue; + } + ids.entry(id).or_insert(url); + } + } + // Every id the run OBSERVED, before the CDN/wasm filter drops any of them: absence + // from the pins then distinguishes "rejected" from "never seen". Taken from + // `observed_ids`, NOT `by_id` — the latter holds only the survivors, so building the + // tombstone from it failed for exactly the case the tombstone exists to cover. Taken from + // , NOT — the latter holds only the survivors, so building the + // tombstone from it failed for precisely the case the tombstone exists to cover. + let live_ids: BTreeSet = resolution.observed_ids.clone(); + let pins = bootloader_pins(&resolution, &ids); + + if payloads.is_empty() { + // Still sweep: an explicit `--wasm-out` that produced nothing must not leave the + // previous run's payloads behind, or a runner would consume a set no lock + // describes. Not an error — a blocked resolution must not fail a spec update. + maybe_save_wasm(opts, &payloads)?; + return Ok((Vec::new(), Some(pins), live_ids)); + } // The wasm cache attaches to the JS cache for the same version; if that isn't there // (e.g. the JS came straight from a download with no `--cache`), skip caching rather @@ -1312,7 +1398,7 @@ fn load_wasm( let entries = wasm_entries(&payloads, &handles); maybe_save_wasm(opts, &payloads)?; - Ok(entries) + Ok((entries, Some(pins), live_ids)) } /// Give every payload a file name that is unique across the set and safe on disk, deriving @@ -1397,6 +1483,70 @@ fn truncate_on_char_boundary(s: &str, max_bytes: usize) -> &str { &s[..end] } +/// The bootloader section of the wasm lock: what this run learned about turning a numeric +/// `bx` handle into something fetchable, which nothing else records. +/// +/// `handles` is the map ACCUMULATED for this version (page + cache + this run), not this +/// run's sample. The endpoint answers the same request with different subsets, so pinning +/// the latest sample would let a later successful run DELETE ids a previous one resolved — +/// while their payloads stay in the set — and the supposedly diffable map would differ +/// between two runs of an unchanged release. +/// +/// Only wasm entries the DOWNLOAD path would accept are pinned. The page's `bxData` is +/// server-supplied and may name any absolute URL; `resolve_wasm_with` already refuses a +/// non-CDN one, but the pins were built from the unfiltered page map behind a +/// filename-suffix check alone, so the committed lockfile — which `restore` treats as +/// trusted — could publish `https://attacker.example/payload.wasm` as a resolved handle +/// that the fetcher itself had rejected. Same predicate as the fetch path, not a second +/// copy of it. +/// +/// Only the wasm entries are pinned, as the field name promises. `handles` starts from the +/// page's `bxData`, the bootloader's map of EVERY resource — images and the rest — while +/// the other two contributors are already wasm-filtered (`by_id` keeps only wasm URLs, and +/// the cache filters against stored wasm payloads). Pinning the lot broke the field's +/// contract and swamped the diff signal it exists for, since unrelated resources churn far +/// more often than the wasm set does. `handles` itself is left whole: `store_wasm` and +/// `wasm_entries` look it up by a payload's own URL, where a non-wasm entry never matches. +#[cfg(feature = "fetch")] +fn bootloader_pins( + resolution: &wa_fetch::WasmResolution, + ids: &BTreeMap, +) -> lock::BootloaderPins { + lock::BootloaderPins { + handles_from_page: resolution.from_page, + wasm_handles: ids + .iter() + .filter(|(_, url)| wa_fetch::is_wasm_url(url) && wa_fetch::is_cdn_payload(url)) + .map(|(id, url)| (id.clone(), url.clone())) + .collect(), + requests: resolution.requests, + failed_requests: resolution.failed_requests, + degradations: resolution.failures.len(), + } +} + +#[cfg(feature = "fetch")] +/// Remove URL → id entries whose id the live response has REBOUND to another URL. +/// +/// The cache's `A -> 11` would otherwise sit beside a fresh `B -> 11`: `wasm_entries` +/// stamps `bxId: 11` on both payloads while the pins, built from the id side where the +/// live value wins, say 11 is B — a lock that contradicts itself. An id this run never +/// saw is left alone; it is still the best record there is. +fn drop_stale_aliases( + handles: &mut BTreeMap, + live_by_id: &BTreeMap, + observed: &BTreeSet, +) { + handles.retain(|url, id| match live_by_id.get(id) { + Some(live_url) => live_url == url, + // Seen but NOT in `by_id`: the live binding was rejected by the wasm/CDN filter. + // Keeping the cached alias would stamp `bxId` on a payload while the pins — which + // tombstone the same id — say it resolves to nothing. Reconciling against the + // post-filter map alone left exactly that contradiction. + None => !observed.contains(id), + }); +} + /// `bx` id → URI inverted into URI → `bx` id, lowest id winning on a shared URI (the /// input is sorted, so the choice is deterministic). #[cfg(feature = "fetch")] @@ -2045,6 +2195,128 @@ struct NotifCounts { drops: std::collections::BTreeMap, } +/// Rewrite only the bootloader section of an existing wasm lock, leaving the payload set +/// it records untouched. +/// +/// Used when a run resolved no payloads: the emptiness guard exists so an unproductive +/// run cannot truncate the committed set, but the bootloader outcome from that same run +/// is the diagnostic worth keeping. A missing or unreadable lock is left alone — there is +/// no payload set to preserve, and inventing one from an empty run is what the guard +/// forbids. +/// +/// The handle map ACCUMULATES over the lock's own previous value; only the request +/// diagnostics are replaced. Everywhere else the accumulation comes from the cache, but +/// the update workflow runs `--wasm-out` with no `--cache`, so on that path the lock is +/// the only surviving record of what earlier runs resolved from the endpoint — and +/// replacing the section wholesale would delete those ids while deliberately keeping the +/// payloads they resolved. That is precisely the "a later run must not shrink the map" +/// rule the pins were introduced to honour. +/// +/// Accumulating is only sound WITHIN one release, so a lock stamped for a different +/// `wa_version` is left untouched. On a version bump that resolves nothing, the lock still +/// pins the previous release's payloads (`warn_if_wasm_lock_is_now_stale` says so); writing +/// this run's handles into it would attribute the new release's ids to the old one, and — +/// because the map accumulates — interleave two releases' ids into a provenance record +/// whose whole purpose is being diffable. +#[cfg(feature = "fetch")] +fn update_lock_bootloader( + lock_path: &Path, + pins: &lock::BootloaderPins, + wa_version: &str, + seen_live: &BTreeSet, +) -> Result<()> { + let raw = fs::read_to_string(lock_path)?; + let mut lock: WasmLock = serde_json::from_str(&raw)?; + if lock.wa_version != wa_version { + eprintln!( + " bootloader map not written: {} is stamped for {}, not {wa_version}", + lock_path.display(), + lock.wa_version + ); + return Ok(()); + } + let mut pins = pins.clone(); + merge_prior_handles(&mut pins, lock.bootloader.as_ref(), seen_live); + let pins = &pins; + if lock.bootloader.as_ref() == Some(pins) { + return Ok(()); + } + lock.bootloader = Some(pins.clone()); + fs::write(lock_path, lock.to_pretty_json()) + .with_context(|| format!("write {}", lock_path.display()))?; + eprintln!( + "updated {} bootloader map ({} wasm handle(s), {} request(s), {} failed)", + lock_path.display(), + pins.wasm_handles.len(), + pins.requests, + pins.failed_requests + ); + Ok(()) +} + +/// Fold the handles a previous run recorded into `pins`, current run winning a collision. +/// +/// The map is a best-effort SUPERSET for one release: the bootloader endpoint answers the +/// same request with different subsets, so whichever run happens to be last must not get +/// to decide the whole map. Its ids are provenance, not identity — they do not enter +/// `wasmSetHash` — so keeping one whose payload this run did not re-resolve costs nothing, +/// while dropping it makes an unchanged release diff against itself. +fn merge_prior_handles( + pins: &mut lock::BootloaderPins, + prior: Option<&lock::BootloaderPins>, + seen_live: &BTreeSet, +) { + let Some(prior) = prior else { return }; + for (id, url) in &prior.wasm_handles { + // An id the run SAW but whose new binding the pins filtered out (rebound to a + // non-CDN or non-wasm URL) is absent from `pins` for a reason. Reading that + // absence as "never observed" restored the stale entry, so the lock kept + // provenance the page had explicitly replaced. `seen_live` is the tombstone. + if seen_live.contains(id) { + continue; + } + pins.wasm_handles.entry(id.clone()).or_insert(url.clone()); + } +} + +/// [`merge_prior_handles`] against whatever the lock on disk already holds, for the write +/// paths that build a whole new [`WasmLock`] rather than editing the existing one. +/// +/// Only within the same release — a lock stamped for another `wa_version` describes a +/// different set of ids, and merging across the two would produce a map belonging to +/// neither. An unreadable or absent lock simply contributes nothing. +/// +/// Deliberately NOT `#[cfg(feature = "fetch")]`: its caller is on the ordinary write path +/// and compiles in every configuration. Gating the callee and not the caller is exactly +/// what broke the no-default-features build twice in this branch. +fn pins_with_prior( + lock_path: &Path, + pins: Option, + wa_version: &str, + seen_live: &BTreeSet, +) -> Option { + let mut pins = pins?; + // A lock that exists but cannot be parsed is NOT the same as one that is absent: the + // ids it held are about to be dropped, and the whole point of accumulating is that + // they must not be. Say so rather than let a corrupt file look like a first run. + let prior = match fs::read_to_string(lock_path) { + Ok(raw) => match serde_json::from_str::(&raw) { + Ok(l) if l.wa_version == wa_version => l.bootloader, + Ok(_) => None, + Err(e) => { + eprintln!( + " {} is unreadable ({e}); its bootloader handles cannot be carried over", + lock_path.display() + ); + None + } + }, + Err(_) => None, + }; + merge_prior_handles(&mut pins, prior.as_ref(), seen_live); + Some(pins) +} + /// Emit the incoming content-stanza read-shape catalog (`incoming/index.json`) — the /// field trees WA Web parses out of received message/receipt/call/ack stanzas. Neutral /// IR only; the codegen is a later phase. @@ -2924,6 +3196,254 @@ mod tests { assert!(names.iter().all(|n| n.ends_with(".wasm") && n.len() <= 128)); } + #[cfg(feature = "fetch")] + #[test] + fn an_empty_run_accumulates_the_bootloader_map_instead_of_replacing_it() { + // The update workflow runs `--wasm-out` with NO `--cache`, so on that path the + // lock is the only record of what earlier runs resolved from the endpoint. A + // wholesale replace would drop those ids while keeping the payloads they + // resolved — the exact shrink the pins exist to prevent. Diagnostics are the + // opposite: they describe THIS run, so they must not accumulate. + let dir = std::env::temp_dir().join(format!("ws-boot-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir"); + let path = dir.join("wasm.lock.json"); + let pins = |handles: &[(&str, &str)], requests, failed| lock::BootloaderPins { + handles_from_page: 1, + wasm_handles: handles + .iter() + .map(|(a, b)| (a.to_string(), b.to_string())) + .collect(), + requests, + failed_requests: failed, + degradations: 0, + }; + + let before = lock::WasmLock::with_bootloader( + "2.3000.test", + vec![], + Some(pins(&[("11", "https://x/a.wasm")], 4, 0)), + ); + fs::write(&path, before.to_pretty_json()).expect("seed the lock"); + + // An unproductive run that saw only the page's own handle. + update_lock_bootloader( + &path, + &pins(&[("22", "https://x/b.wasm")], 1, 1), + "2.3000.test", + &BTreeSet::new(), + ) + .expect("rewrite the section"); + + let after: lock::WasmLock = + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + let boot = after.bootloader.expect("bootloader section"); + assert_eq!( + boot.wasm_handles.keys().collect::>(), + ["11", "22"], + "the earlier endpoint-resolved id survives" + ); + assert_eq!(boot.requests, 1, "diagnostics describe THIS run"); + assert_eq!(boot.failed_requests, 1); + + // ...but only within one release. On a version bump that resolved nothing, the + // lock still describes the PREVIOUS release, so accumulating this run's ids into + // it would interleave two releases in a record whose point is being diffable. + update_lock_bootloader( + &path, + &pins(&[("33", "https://x/c.wasm")], 9, 9), + "2.3000.next", + &BTreeSet::new(), + ) + .expect("a stale lock is not an error"); + let untouched: lock::WasmLock = + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!( + untouched.bootloader.expect("still there"), + boot, + "a lock stamped for another version is left exactly as it was" + ); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_productive_run_accumulates_the_handle_map_too() { + // The empty-run path was fixed first, but a run that resolves a SMALLER endpoint + // subset also succeeds — and rebuilding the lock from its own sample deletes ids + // an earlier run resolved. Same shrink, reached by a productive run. Without + // `--cache` (which is how the update workflow runs) the lock is the only record, + // so nothing else would have restored them. + let dir = std::env::temp_dir().join(format!("ws-boot-nonempty-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir"); + let path = dir.join("wasm.lock.json"); + let pins = |handles: &[(&str, &str)]| lock::BootloaderPins { + handles_from_page: 1, + wasm_handles: handles + .iter() + .map(|(a, b)| (a.to_string(), b.to_string())) + .collect(), + requests: 4, + failed_requests: 0, + degradations: 0, + }; + let seed = lock::WasmLock::with_bootloader( + "2.3000.test", + vec![], + Some(pins(&[ + ("11", "https://x/a.wasm"), + ("22", "https://x/b.wasm"), + ])), + ); + fs::write(&path, seed.to_pretty_json()).expect("seed the lock"); + + // This run saw only one of the two, and one the earlier run had not. + let merged = pins_with_prior( + &path, + Some(pins(&[ + ("22", "https://x/b.wasm"), + ("33", "https://x/c.wasm"), + ])), + "2.3000.test", + &BTreeSet::new(), + ) + .expect("pins were supplied"); + assert_eq!( + merged.wasm_handles.keys().collect::>(), + ["11", "22", "33"], + "the id this run did not re-resolve survives" + ); + + // A lock for another release contributes nothing — merging across two would + // produce a map belonging to neither. + let isolated = pins_with_prior( + &path, + Some(pins(&[("33", "https://x/c.wasm")])), + "2.3000.next", + &BTreeSet::new(), + ) + .expect("pins were supplied"); + assert_eq!(isolated.wasm_handles.keys().collect::>(), ["33"]); + + // An id the run SAW but whose new binding the CDN filter dropped must not be + // restored from the prior lock: absent-from-pins means rejected, not unseen. + let tombstoned = pins_with_prior( + &path, + Some(pins(&[("22", "https://x/b.wasm")])), + "2.3000.test", + &BTreeSet::from(["11".to_string()]), + ) + .expect("pins were supplied"); + assert_eq!( + tombstoned.wasm_handles.keys().collect::>(), + ["22"], + "the page rebound 11 to something the filter refused; the stale URL stays gone" + ); + fs::remove_dir_all(&dir).unwrap(); + } + + #[cfg(feature = "fetch")] + #[test] + fn a_rebound_handle_drops_its_stale_url() { + // The cache says 11 resolved A; the live response says 11 resolves B. Keeping both + // `A -> 11` and `B -> 11` in the URL-keyed map would stamp `bxId: 11` on two + // payloads while the pins — built from the id side, where the live value wins — + // say 11 is B. The lock would contradict itself. + let live: BTreeMap = [( + "11".to_string(), + "https://static.whatsapp.net/b.wasm".to_string(), + )] + .into_iter() + .collect(); + let mut handles: BTreeMap = [ + ("https://static.whatsapp.net/a.wasm", "11"), + ("https://static.whatsapp.net/b.wasm", "11"), + ("https://static.whatsapp.net/c.wasm", "22"), + ] + .iter() + .map(|(u, i)| (u.to_string(), i.to_string())) + .collect(); + drop_stale_aliases(&mut handles, &live, &BTreeSet::from(["11".to_string()])); + assert_eq!( + handles.keys().collect::>(), + [ + "https://static.whatsapp.net/b.wasm", + "https://static.whatsapp.net/c.wasm" + ], + "the stale alias for a rebound id goes; an id the run never saw stays" + ); + + // ...and an id the run SAW but whose live binding the filter rejected loses its + // cached alias too: absence from `by_id` is not absence from the response. + let mut cached: BTreeMap = [( + "https://static.whatsapp.net/a.wasm".to_string(), + "11".to_string(), + )] + .into_iter() + .collect(); + drop_stale_aliases( + &mut cached, + &BTreeMap::new(), + &BTreeSet::from(["11".to_string()]), + ); + assert!( + cached.is_empty(), + "the page rebound 11 to something refused; its cached URL must not survive" + ); + } + + #[cfg(feature = "fetch")] + #[test] + fn only_wasm_handles_are_pinned() { + // The page's `bxData` maps EVERY resource, not just wasm, and it is one of the + // three sources folded into `handles`. Pinning the lot broke the field's own + // contract and swamped its diff signal with images that change far more often + // than the wasm set does. + // id -> URL, the direction the page and the resolver actually record: two ids may + // alias one wasm URL, and the URL-keyed index this used to be built from kept only + // the lowest of them. + let handles: BTreeMap = [ + ("11", "https://static.whatsapp.net/a.wasm"), + ("22", "https://static.whatsapp.net/logo.png"), + ("33", "https://static.whatsapp.net/b.wasm?v=3"), + ("44", "https://static.whatsapp.net/a.wasm"), + // Server-supplied `bxData` may name any host. The fetcher refuses these; the + // lockfile is trusted by `restore`, so it must refuse them too. + ("55", "https://attacker.example/payload.wasm"), + ("66", "http://static.whatsapp.net/downgraded.wasm"), + ( + "77", + "https://static.whatsapp.net:443@attacker.example/x.wasm", + ), + ] + .iter() + .map(|(i, u)| (i.to_string(), u.to_string())) + .collect(); + let resolution = wa_fetch::WasmResolution { + from_page: 1, + requests: 2, + failed_requests: 0, + // Degradation that never reached a request: the pins must still carry it, or + // a capped run whose requests all succeeded looks identical to a clean one. + failures: vec!["component list capped".into()], + ..Default::default() + }; + let pins = bootloader_pins(&resolution, &handles); + assert_eq!( + pins.wasm_handles.keys().collect::>(), + ["11", "33", "44"], + "the image is dropped, a query string does not hide a .wasm, an aliasing id \ + is kept, and nothing off the CDN is pinned" + ); + // The diagnostics still come from the resolution, untouched by the filter. + assert_eq!((pins.handles_from_page, pins.requests), (1, 2)); + assert_eq!( + (pins.failed_requests, pins.degradations), + (0, 1), + "no request failed, but coverage was still short" + ); + } + #[test] fn truncation_never_splits_a_character() { // A non-ASCII segment must not panic the run. diff --git a/crates/whatspec/src/restore.rs b/crates/whatspec/src/restore.rs index 50569c7e..9a1788a2 100644 --- a/crates/whatspec/src/restore.rs +++ b/crates/whatspec/src/restore.rs @@ -1109,7 +1109,7 @@ mod tests { // ── wasm restore ──────────────────────────────────────────────────────────────── fn wasm_lock_for(entries: &[(&str, &str, &[u8])]) -> crate::lock::WasmLock { - crate::lock::WasmLock::new( + crate::lock::WasmLock::with_bootloader( "2.3000.TEST", entries .iter() @@ -1121,6 +1121,7 @@ mod tests { size: bytes.len() as u64, }) .collect(), + None, ) } diff --git a/generated/iq/index.json b/generated/iq/index.json index 92e3e9f0..b4c8f4db 100644 --- a/generated/iq/index.json +++ b/generated/iq/index.json @@ -34905,43 +34905,205 @@ "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "ALL_NONE_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "contact_blacklist", + "value": "contact_blacklist" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "ONLINE_VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "match_last_seen", + "value": "match_last_seen" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "contact_blacklist", + "value": "contact_blacklist" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "contact_blacklist", + "value": "contact_blacklist" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "contact_blacklist", + "value": "contact_blacklist" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "CALL_ADD_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "known", + "value": "known" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", @@ -34987,7 +35149,33 @@ "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "contact_blacklist", + "value": "contact_blacklist" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } } ], "repeats": true @@ -35004,43 +35192,205 @@ "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "ALL_NONE_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "contact_blacklist", + "value": "contact_blacklist" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "ONLINE_VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "match_last_seen", + "value": "match_last_seen" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "contact_blacklist", + "value": "contact_blacklist" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "contact_blacklist", + "value": "contact_blacklist" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "contact_blacklist", + "value": "contact_blacklist" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "CALL_ADD_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "known", + "value": "known" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "error", + "value": "error" + } + ] + } }, { "method": "attrEnum", @@ -35086,7 +35436,33 @@ "method": "attrEnum", "name": "value", "type": "enum", - "required": true + "required": true, + "enumRef": { + "name": "VISIBILITY_WITH_ERROR", + "module": "WAWebPrivacySettings", + "variants": [ + { + "name": "all", + "value": "all" + }, + { + "name": "contacts", + "value": "contacts" + }, + { + "name": "contact_blacklist", + "value": "contact_blacklist" + }, + { + "name": "none", + "value": "none" + }, + { + "name": "error", + "value": "error" + } + ] + } } ] } diff --git a/generated/manifest.json b/generated/manifest.json index 80ea8403..9c98e534 100644 --- a/generated/manifest.json +++ b/generated/manifest.json @@ -12,7 +12,7 @@ "constraints": { "errorArms": 645, "errorTexts": 577, - "fieldEnumRefs": 151, + "fieldEnumRefs": 167, "fieldLiterals": 1784, "referenceConstraints": 584, "typedErrorVariants": 104 @@ -26,7 +26,7 @@ "dropsByReason": { "iq builder substring present but no AST iq call": 5, "mixin fragment (folded into requests, not a standalone stanza)": 66, - "response enum argument not structurally resolvable": 41 + "response enum argument not structurally resolvable": 33 }, "excludedFragments": 66, "stanzas": 143, @@ -69,7 +69,7 @@ "iq": { "file": "iq/index.json", "schema": "schema/iq.schema.json", - "sha256": "455d2a98f9e9ead8311bd8b5fc2cbadfabf6247ce699b5fa5d36f3bb796b3a28" + "sha256": "f733ed4de82a381f600f38570e94b3b682d06f898dc60329b71aeb257c7f0671" }, "mex": { "file": "mex/index.json", @@ -79,7 +79,7 @@ "notif": { "file": "notif/index.json", "schema": "schema/notif.schema.json", - "sha256": "473f4d20fefe3136b264f3398811fb9af85dc5fca59022b727ca16f83420cd33" + "sha256": "c140136bbad10bf1412de40aa6408792530e27ae25b7606a400bed10c05aaf6e" }, "proto": { "file": "proto/WAProto.proto", diff --git a/generated/notif/index.json b/generated/notif/index.json index 2616b04c..6b6257f1 100644 --- a/generated/notif/index.json +++ b/generated/notif/index.json @@ -1834,7 +1834,8 @@ "type": "string", "required": false } - ] + ], + "required": true } ] }, @@ -1908,7 +1909,8 @@ "type": "string", "required": false } - ] + ], + "required": true } ] }, @@ -1962,7 +1964,8 @@ "type": "string", "required": false } - ] + ], + "required": true } ] }, @@ -2016,7 +2019,8 @@ "type": "string", "required": false } - ] + ], + "required": true } ] }, @@ -2078,7 +2082,8 @@ "type": "string", "required": false } - ] + ], + "required": true } ] }, @@ -2140,7 +2145,8 @@ "type": "string", "required": false } - ] + ], + "required": true } ] }, @@ -2194,7 +2200,8 @@ "type": "string", "required": false } - ] + ], + "required": true } ] }, @@ -2413,7 +2420,8 @@ "type": "integer", "required": true } - ] + ], + "required": true } ] }, @@ -2464,7 +2472,8 @@ "type": "integer", "required": true } - ] + ], + "required": true } ] }, @@ -2494,7 +2503,8 @@ "type": "integer", "required": true } - ] + ], + "required": true } ] }, @@ -2524,7 +2534,8 @@ "type": "integer", "required": true } - ] + ], + "required": true } ] }, @@ -2554,7 +2565,8 @@ "type": "integer", "required": true } - ] + ], + "required": true } ] }, @@ -2583,7 +2595,8 @@ "type": "integer", "required": true } - ] + ], + "required": true } ] }, @@ -2699,7 +2712,8 @@ "type": "user_jid", "required": false } - ] + ], + "required": true } ] }, @@ -2717,7 +2731,8 @@ "type": "user_jid", "required": true } - ] + ], + "required": true } ] }, @@ -2792,7 +2807,8 @@ "type": "user_jid", "required": true } - ] + ], + "required": true } ] }, @@ -2824,7 +2840,8 @@ "type": "group_jid", "required": true } - ] + ], + "required": true } ] }, diff --git a/generated/schema/notif.schema.json b/generated/schema/notif.schema.json index e13a2d5e..d9fcd324 100644 --- a/generated/schema/notif.schema.json +++ b/generated/schema/notif.schema.json @@ -183,6 +183,11 @@ "description": "The output field the mapped list lands in (`\"participants\"`).", "type": "string" }, + "required": { + "description": "Whether EVERY branch producing this action carries the collection.\n\n`if (c) return {actionType: A, participants: …}; return {actionType: A}` yields two\nlegal shapes for one action, and only one of them has `participants`. Without this\nthe metadata claimed the list is always present — the same over-assertion that\n`required` on a scalar field exists to prevent, one level up.\n\nAlways serialized. Skipping the `true` case would leave a non-Rust consumer —\nwhich is who the IR is for — unable to tell \"required by default\" from\n\"unspecified\", and the JSON Schema cannot carry the default either.\n\nThe JSON Schema cannot say so: `serde(default)` makes schemars drop the property\nfrom `required`, and `schemars(required)` is a no-op on a non-`Option` field. A\ndocument omitting it therefore validates clean, which is the ambiguity above\nreappearing through the published contract — so `lint-ir.py` enforces the presence\ninstead. The serde default stays, so an older document still deserializes.", + "type": "boolean", + "default": true + }, "wireTag": { "description": "The repeated element's wire tag (`\"participant\"`).", "type": "string" diff --git a/scripts/lint-ir.py b/scripts/lint-ir.py new file mode 100755 index 00000000..52e732a6 --- /dev/null +++ b/scripts/lint-ir.py @@ -0,0 +1,1436 @@ +#!/usr/bin/env python3 +"""Check invariants of the generated IR that the JSON Schemas cannot express. + +The schemas validate SHAPE: that a field has the keys it should, of the right +types. They cannot say whether the document is *usable* — whether a field the IR +calls an enum actually names its legal values, whether a byte pin is the length +it claims, whether a union carries the variants that make it a union. Those are +the questions a consumer writing a generator in any language has to answer, and +they are what this checks. + +Two kinds of finding: + +* **errors** — an internal contradiction. A `literalValue` on an integer field + that is not an integer is not a gap in extraction; it is a document that says + two things at once. These always fail. +* **counted** — a known, legitimately non-empty state, held to a BASELINE. An + enum WA composes at runtime cannot be resolved, and the IR records that under + `dropsByReason` rather than pretending; the count may shrink (extraction + improved) but a rise means a new construct started slipping through unnoticed. + +Network-free and deterministic, so it runs in CI beside the schema validation. + +Usage: scripts/lint-ir.py [generated-dir] (default: ./generated) +""" +import json +import re +import sys +from collections import defaultdict +from pathlib import Path + +# Counted states, with the value observed when this guard was introduced. Raising +# one of these is a deliberate act: it means a constraint the extractor used to +# recover is now being lost, and the number has to be updated with a reason. +BASELINE = { + "content integer with no byte width": 0, +} + +# The enums no extraction path could resolve, by IDENTITY rather than by total. +# +# A single number cannot see a SUBSTITUTION: one field gaining values while another loses +# them holds the sum at 155 and reports `ok`, which is precisely the silent constraint loss +# this file exists to catch. Identity is domain + field name + wire name + accessor, so it +# survives reordering — a positional path would churn on every unrelated insertion. The +# counts are multiplicities: the same wire enum is read at several places. +# +# 155 fields collapse to 30 identities. Adding one, dropping one, or changing a count all +# fail; each is a deliberate act that costs one line here. +# Arrays where the extractor FLATTENED a shape discriminated by an attribute, keyed by +# semantic path and the repeated names. `privacyParser` reads `` into ten sibling `value` fields, losing the discriminator linkage and the +# runtime's output names (`lastSeen`, `callAdd`, …); the same ten also appear correctly +# nested under `category`, so the flat copy is a duplicate rather than the only record. +# +# By identity, not by total: an aggregate let one loss disappear while another appeared. +# Fixing the shape is an extractor change and belongs in its own PR. +FLATTENED_KEYS = { + "incoming/incoming/3/shape/fields/5/children/0/children|type": 1, + "incoming/incoming/4/shape/fields|id": 1, + "iq/stanzas/22/response/fields|id": 1, + "iq/stanzas/49/response/fields/0/children/0/children|value": 9, + "iq/stanzas/49/response/fields|value": 9, + "iq/stanzas/50/response/fields/0/children/0/children|value": 1, + "iq/stanzas/50/response/fields|value": 1, + "notif/notifications/0/content/fields|id": 1, + "notif/notifications/12/content/fields|id": 1, + "notif/notifications/17/content/fields|from,t": 3, + "notif/notifications/19/content/fields|id": 1, + "notif/notifications/23/content/fields|from": 2, + "notif/notifications/24/content/fields|jid,participant,t": 8, + "notif/notifications/3/content/fields/7/children|new_lid,old_lid": 2, + "notif/notifications/3/content/fields|t": 1, +} + +UNRESOLVED_ENUMS = { + "incoming|ack|deprecatedEditMixin|edit|attrEnum": 1, + "iq|AddParticipantsResponseSuccess|participant|addParticipant|addParticipantsParticipantAddedOrNonRegisteredWaUserParticipantErrorLidResponseMixinGroup|AddParticipantsParticipantAddedResponse|addParticipantsParticipantMixins|ParticipantGroupJoinRequest|membershipApprovalRequestError|maybeAttrEnum": 1, + "iq|CreateCustomPaymentMethodResponseSuccess|custom_payment_method|accountCustomPaymentMethodCustomPaymentMethodMixin|p2mEligible|maybeAttrEnum": 1, + "iq|CreateCustomPaymentMethodResponseSuccess|custom_payment_method|accountCustomPaymentMethodCustomPaymentMethodMixin|p2pEligible|maybeAttrEnum": 1, + "iq|CreateResponseSuccess|description|groupDescription|error|maybeAttrEnum": 1, + "iq|PromoteDemoteAdminResponseSuccessMultiAdmin|participant|adminParticipant|error|maybeAttrEnum": 1, + "iq|PromoteDemoteResponseSuccessDemote|participant|demoteParticipant|error|maybeAttrEnum": 1, + "iq|PromoteDemoteResponseSuccessPromote|participant|promoteParticipant|error|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultCreditP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultCreditP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultCredit|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultDebitP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultDebitP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|defaultDebit|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|isAadhaarEnabled|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|isInternationalPayEnabled|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|isMpinSet|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|p2mEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|p2pEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|bank|bank|pinFormatVersion|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|automaticBinding|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligible|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesEditable|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesVerifiable|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|comboCardCapabilitiesMixin|capabilitiesP2mCreditEligible|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|comboCardCapabilitiesMixin|capabilitiesP2mDebitEligible|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCreditP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCreditP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCredit|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebitP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebitP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebit|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|needsDeviceBinding|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|p2mEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|p2pEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|verified|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligible|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesEditable|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesVerifiable|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCreditP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCreditP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCredit|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebitP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebitP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebit|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|p2mEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|p2pEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|verified|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligible|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesEditable|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesVerifiable|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCreditP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCreditP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCredit|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebitP2m|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebitP2p|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebit|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|p2mEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|p2pEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|verified|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|custom_payment_method|customPaymentMethod|p2mEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|custom_payment_method|customPaymentMethod|p2pEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|canAddPayout|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|canPayout|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|canSell|attrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|p2mEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|p2pEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutBank|p2mEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutBank|p2pEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutPrepaidCard|p2mEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutPrepaidCard|p2pEligible|maybeAttrEnum": 1, + "iq|RemoveCustomPaymentMethodResponseSuccess|account|accountPaymentMethodsMixin|merchant|merchant|pixOnboardingState|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|defaultCreditP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|defaultCreditP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|defaultCredit|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|defaultDebitP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|defaultDebitP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|defaultDebit|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|isAadhaarEnabled|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|isInternationalPayEnabled|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|isMpinSet|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|p2mEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|p2pEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|bank|bank|pinFormatVersion|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|automaticBinding|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesDefaultEligible|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesEditable|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|capabilitiesVerifiable|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|comboCardCapabilitiesMixin|capabilitiesP2mCreditEligible|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|comboCardCapabilitiesMixin|capabilitiesP2mDebitEligible|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCreditP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCreditP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultCredit|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebitP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebitP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|defaultDebit|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|needsDeviceBinding|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|p2mEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|p2pEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|BRCard|verified|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesDefaultEligible|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesEditable|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|capabilitiesVerifiable|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCreditP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCreditP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultCredit|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebitP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebitP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|defaultDebit|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|p2mEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|p2pEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|ESCard|verified|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligibleP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligibleP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesDefaultEligible|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesEditable|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|capabilitiesVerifiable|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCreditP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCreditP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultCredit|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebitP2m|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebitP2p|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|defaultDebit|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|p2mEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|p2pEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|card|card|bROrMXOrESCardMixinGroup|MXCard|verified|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|custom_payment_method|customPaymentMethod|p2mEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|custom_payment_method|customPaymentMethod|p2pEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|merchant|merchant|canAddPayout|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|merchant|merchant|canPayout|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|merchant|merchant|canSell|attrEnum": 1, + "iq|account|accountPaymentMethodsMixin|merchant|merchant|p2mEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|merchant|merchant|p2pEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutBank|p2mEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutBank|p2pEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutPrepaidCard|p2mEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|merchant|merchant|payout|payout|payoutBankOrPrepaidCardMixinGroup|PayoutPrepaidCard|p2pEligible|maybeAttrEnum": 1, + "iq|account|accountPaymentMethodsMixin|merchant|merchant|pixOnboardingState|maybeAttrEnum": 1, + "iq|custom_payment_method|accountCustomPaymentMethodCustomPaymentMethodMixin|p2mEligible|maybeAttrEnum": 1, + "iq|custom_payment_method|accountCustomPaymentMethodCustomPaymentMethodMixin|p2pEligible|maybeAttrEnum": 1, + "iq|description|groupDescription|error|maybeAttrEnum": 1, + "iq|participant|addParticipant|addParticipantsParticipantAddedOrNonRegisteredWaUserParticipantErrorLidResponseMixinGroup|AddParticipantsParticipantAddedResponse|addParticipantsParticipantMixins|ParticipantGroupJoinRequest|membershipApprovalRequestError|maybeAttrEnum": 1, + "iq|participant|adminParticipant|error|maybeAttrEnum": 1, + "iq|participant|promoteParticipant|error|maybeAttrEnum": 1, + "notif|create|reason|": 1, +} + + +# `contentInt` reads DECIMAL TEXT, so it has no byte width and must not be asked +# for one; `contentUint(N)` reads N big-endian bytes and must always carry it. +WIDTH_BEARING = {"contentUint"} + +# `WamChannel`. Anything else is mapped to `Regular` by the reference generator rather +# than preserved, so an unknown value loses the channel silently instead of failing. +WAM_CHANNELS = {"regular", "realtime", "private"} + +# What each accessor may decode to. Deliberately a SET per accessor, not one type: an +# `attrInt` legitimately backs `integer`, `timestamp` and `timestamp_millis`, and a +# one-to-one map would have failed CI on 27 correct fields. Only accessors whose vocabulary +# is settled are listed; anything else is left alone rather than guessed at. +ACCESSOR_TYPES = { + "attrInt": {"integer", "timestamp", "timestamp_millis"}, + "attrString": {"string"}, + "contentUint": {"integer"}, + "contentBytes": {"bytes"}, + "attrEnum": {"enum"}, + "maybeAttrEnum": {"enum"}, +} + +# `contentUint(N)` reads N big-endian bytes and `contentBytes` a fixed run; nothing else +# has a byte width. A node with NO accessor is request-side (a `constBytes` pin), which +# carries the length legitimately — 20 of them do. +WIDTH_ACCESSORS = {"contentUint", "contentBytes"} + +# The protobuf message appstate actions nest under. Their `protoEnum`/`valueEnumFields` +# paths are written relative to it, so it is the one prefix that may be elided. +APPSTATE_ROOT = "SyncActionValue" + +# What the extractor emits for an integer pin, and what a consumer must be able to parse. +DECIMAL = re.compile(r"-?[0-9]+") + +# Keys only a response field carries. They identify a `ParsedField` independently of +# whether its `type` is one we know — which is what lets an unrecognized type be +# REPORTED instead of skipped. Deliberately excludes `name`/`type` alone: appstate has +# its own vocabulary (`literal`, `boolString`, `jidOrZero`, proto type names) and the +# notification envelope carries `type` as the notification kind, so keying on those +# would flag correct documents. +FIELD_MARKERS = { + "method", "wireName", "enumRef", "enumKeys", "literalValue", "byteLength", + "byteMin", "byteMax", "intMin", "intMax", "unionVariants", "referencePath", +} + +# The `ParsedFieldType` vocabulary, mirroring `wa_ir::ParsedFieldType`. +FIELD_TYPES = { + "string", "integer", "timestamp", "timestamp_millis", "enum", "bytes", + "jid", "user_jid", "lid_user_jid", "device_jid", "lid_device_jid", + "group_jid", "newsletter_jid", "call_jid", "broadcast_jid", "status_jid", + "jid_typed", "bool", "union", +} + + +def collect_unresolved_enums(data, domain, proto_enums, out): + """Every enum field no extraction path could resolve, keyed by SEMANTIC path. + + The key is the chain of tags and names from the document root down to the field, so it + survives reordering — a positional path would churn on every unrelated insertion. + Aggregating by `domain|name|wireName|method` was not enough: one key stood for as many + as 18 distinct fields, and a substitution WITHIN a key kept its count and passed. Along + this path all 155 are distinct, so any substitution changes the set. + """ + + def walk_(node, trail): + if isinstance(node, dict): + step = [] + if node.get("tag"): + step.append(str(node["tag"])) + elif node.get("wireTag"): + step.append(str(node["wireTag"])) + if node.get("name"): + step.append(str(node["name"])) + here = trail + step + if ( + node.get("type") == "enum" + and not node.get("enumRef") + and not node.get("enumKeys") + and not (node.get("protoEnum") and node["protoEnum"] in proto_enums) + ): + out["|".join([domain, *here, str(node.get("method", ""))])] += 1 + for k, v in node.items(): + if k in ("tag", "wireTag", "name"): + continue + walk_(v, here) + elif isinstance(node, list): + for v in node: + walk_(v, trail) + + walk_(data, []) + + +def collect_sync_action_fields(path): + """`fieldName -> MessageType` for the direct fields of `message SyncActionValue`. + + Scanned by brace DEPTH, not by a non-greedy match: the message contains nested ones, + and a regex stopping at the first `}` reported all 62 fields as missing. + """ + try: + text = path.read_text() + except OSError: + return {} + out, depth, inside = {}, 0, False + for line in text.splitlines(): + stripped = line.split("//")[0].strip() + if not inside and re.match(r"message\s+SyncActionValue\s*\{", stripped): + inside, depth = True, 1 + continue + if not inside: + continue + if depth == 1: + m = re.match(r"(?:optional|required|repeated)\s+([\w.]+)\s+(\w+)\s*=", stripped) + if m: + out[m.group(2)] = m.group(1) + depth += stripped.count("{") - stripped.count("}") + if depth <= 0: + break + return out + + +def collect_proto_messages(path): + """`Outer.Inner` for every protobuf MESSAGE, in the same two forms as the enums. + + `valueProtoType` names one of these, and nothing resolved it — a typo or a removed + message left mutation tooling unable to build the `SyncActionValue` payload the IR + claims, while the linter certified the document. + """ + return _collect_proto(path, "message") + + +def collect_proto_enums(path): + """`Outer.Inner` for every ENUM in the committed `.proto`, by brace depth. + + Cheap and text-based on purpose: the linter must stay dependency-free, and the file is + generated by this same repo. A path naming a message that has no such enum, or naming + nothing at all, is a dangling reference the schema cannot catch. + """ + return _collect_proto(path, "enum") + + +def _collect_proto(path, want): + try: + text = path.read_text() + except OSError: + return set() + out, stack = set(), [] + + def enclosing(): + for name in reversed(stack): + if name is not None: + return name + return None + + for line in text.splitlines(): + line = line.split("//")[0].strip() + opens, closes = line.count("{"), line.count("}") + m = re.match(r"(message|enum)\s+(\w+)", line) + if m and opens: + name = m.group(2) + if m.group(1) == want: + # Every qualification, not just the immediate parent: a doubly nested enum + # is written `Outer.Inner.E` in the IR, and emitting only `Inner.E` made a + # real reference read as dangling (`WASARootSecretAction.RootSecretEntry. + # Status` was the case that surfaced it). + # The full path, plus the one form the IR actually writes: appstate paths + # are relative to `SyncActionValue`, the message every action nests under. + # + # Registering EVERY suffix instead was permissive — a bare `Status` + # resolved — and registering only the absolute path was wrong the other + # way, since the IR never spells `SyncActionValue`. Exactly these two. + named = [n for n in stack if n is not None] + full = [*named, name] + out.add(".".join(full)) + if full[0] == APPSTATE_ROOT and len(full) > 1: + out.add(".".join(full[1:])) + stack.append(name) + opens -= 1 + # `oneof`, options, anything else braced. Popping on their `}` as if it closed the + # enclosing MESSAGE was what made a nested enum come out unqualified — and a real + # `Outer.E` reference then read as dangling. + stack.extend([None] * opens) + for _ in range(closes): + if stack: + stack.pop() + return out + + +def walk(node, visit, path=""): + """Depth-first over every dict in the document, with a readable path.""" + if isinstance(node, dict): + visit(node, path) + for k, v in node.items(): + walk(v, visit, f"{path}/{k}") + elif isinstance(node, list): + for i, v in enumerate(node): + walk(v, visit, f"{path}/{i}") + + +def check_field(f, path, domain, errors, counts, proto_enums): + """Invariants of one `ParsedField`-shaped object.""" + t = f.get("type") + method = f.get("method", "") + + # An enum that names no legal values is the "is this unconstrained, or did we + # lose the constraint?" ambiguity the IR exists to remove. + # + # `protoEnum` names the values too, just by pointing at a protobuf enum instead of + # inlining them (appstate's `SettingsSync.settingPlatform`). Omitting it counted two + # fully-resolved constraints as lost — and since the baseline is a ratchet, a new + # proto-backed enum could then silently cancel out a genuine unresolved-enum fix + # while the total held still. + if ( + t == "enum" + and not f.get("enumRef") + and not f.get("enumKeys") + and not (f.get("protoEnum") and f["protoEnum"] in proto_enums) + ): + pass # counted by `collect_unresolved_enums`, which can see the semantic path + + if method in WIDTH_BEARING and "byteLength" not in f: + counts["content integer with no byte width"] += 1 + + lv = f.get("literalValue") + # The schema types it as a string; anything else would make the hex scan below iterate + # a non-iterable and abort the whole run — the same "error path assumes a shape it did + # not verify" that already crashed this file once. + if lv is not None and not isinstance(lv, str): + errors.append(f"{path}: literalValue is {type(lv).__name__}, not a string") + lv = None + if lv is not None: + if t == "integer" and not DECIMAL.fullmatch(lv): + # NOT `int()`: Python accepts `1_000`, surrounding whitespace and non-ASCII + # digits, none of which the extractor emits and none of which a Rust (or most + # other) integer parser will take. The grammar is the contract, not what one + # language happens to tolerate. + errors.append(f"{path}: literalValue {lv!r} is not a canonical decimal integer") + if t == "bytes": + if any(c not in "0123456789abcdef" for c in lv) or len(lv) % 2: + errors.append(f"{path}: literalValue {lv!r} is not lowercase hex") + elif "byteLength" in f and not ( + isinstance(f["byteLength"], int) and not isinstance(f["byteLength"], bool) + ): + errors.append(f"{path}: byteLength is {f['byteLength']!r}, not an integer") + elif "byteLength" in f and len(lv) != f["byteLength"] * 2: + errors.append( + f"{path}: literalValue is {len(lv) // 2} bytes, " + f"byteLength says {f['byteLength']}" + ) + + # A union whose alternatives are absent is not a union — a consumer has + # nothing to switch on. + if t == "union": + variants = f.get("unionVariants") or [] + if len(variants) < 2: + errors.append(f"{path}: union carries fewer than two variants") + else: + # Two entries are not two ALTERNATIVES if they answer to the same name. The + # name is the documented discriminator and the Rust codegen emits it as the + # variant identifier, so a repeat is either ambiguous or uncompilable. + # Only real names: a missing one is `None`, and two of those would both + # count as a duplicate AND make the report `sorted()` a `None` against a + # `str`. Absence is not a repeat. + # An EMPTY name is not a name. The codegen runs it through `pascal_case`, + # which yields the reserved `_` and emits `enum E { _ }` — invalid Rust — so + # treating "" as a valid unique discriminator certifies something that cannot + # be generated. + for i, v in enumerate(variants): + if isinstance(v, dict) and v.get("name") == "": + errors.append(f"{path}: union alternative {i} has an empty name") + # NORMALIZED, as the generator sees them: `collect_union` runs each through + # `pascal_case`, so `fooBar` and `foo-bar` emit one variant twice. Same rule + # already applied to appstate collections and scopes. + names = [ + pascal_case(v["name"]) + for v in variants + if isinstance(v, dict) and isinstance(v.get("name"), str) and v["name"] + ] + repeated = sorted({n for n in names if names.count(n) > 1}) + if repeated: + errors.append( + f"{path}: union alternative names collide once normalized ({repeated})" + ) + + # Each pair is the two bounds of ONE range accessor. Inverted is a contradiction; + # so is half of one — the schema permits either key alone, but a consumer handed + # `intMin` with no `intMax` has a weaker constraint than the wire actually enforces + # and no way to know it lost the other end. + for (lo, hi), owner in ((("byteMin", "byteMax"), "bytes"), (("intMin", "intMax"), "integer")): + if (lo in f) != (hi in f): + present, missing = (lo, hi) if lo in f else (hi, lo) + errors.append(f"{path}: {present} without {missing} is half a range") + elif lo in f: + # Compared only once both are numbers: `>` between a str and an int raises, + # and an error path that crashes on malformed input is the failure this file + # has already hit twice. + if not all(isinstance(f[k], int) and not isinstance(f[k], bool) for k in (lo, hi)): + errors.append(f"{path}: {lo}/{hi} are {f[lo]!r}/{f[hi]!r}, not integers") + elif f[lo] > f[hi]: + errors.append(f"{path}: {lo} {f[lo]} exceeds {hi} {f[hi]}") + # A byte range belongs to a bytes field and an integer range to an integer one. + # On any other type the two halves instruct a consumer to do incompatible things + # — a string with a numeric range — and it either builds a nonsense validator or + # drops the constraint. Today every one of them sits on its own type. + if lo in f and t != owner: + errors.append(f"{path}: {lo}/{hi} on a {t!r} field, not {owner!r}") + + # A fixed length outside the declared range is two claims no payload can satisfy. + bl = f.get("byteLength") + if isinstance(bl, int) and not isinstance(bl, bool): + lo, hi = f.get("byteMin"), f.get("byteMax") + if isinstance(lo, int) and not isinstance(lo, bool) and bl < lo: + errors.append(f"{path}: byteLength {bl} is below byteMin {lo}") + if isinstance(hi, int) and not isinstance(hi, bool) and bl > hi: + errors.append(f"{path}: byteLength {bl} is above byteMax {hi}") + + # The accessor and the declared type have to agree: `attrInt` with `type: "string"` + # tells a consumer to read the wire one way and represent it another. + allowed = ACCESSOR_TYPES.get(method) + if allowed is not None and t not in allowed: + errors.append( + f"{path}: {method!r} decodes {sorted(allowed)}, not {t!r}" + ) + + # A byte width belongs to an accessor that HAS one. + if "byteLength" in f and method and method not in WIDTH_ACCESSORS: + errors.append(f"{path}: byteLength on {method!r}, which has no byte width") + + # A `*Source` is provenance FOR an inferred value. Alone it tells a consumer where a + # constraint allegedly came from and not what to enforce. + for src_key, value_keys in ( + ("byteLengthSource", ("byteLength",)), + ("valueSource", ("value", "constBytes")), + ): + if src_key not in f: + continue + if not isinstance(f[src_key], str) or not f[src_key]: + errors.append(f"{path}: {src_key} is {f[src_key]!r}, not a non-empty string") + if not any(k in f for k in value_keys): + errors.append( + f"{path}: {src_key} without {' or '.join(value_keys)} — provenance for " + f"a value that is not there" + ) + + # An echo rule with no path says "this equals something in the request" and + # then does not say what. + if "referencePath" in f and not f["referencePath"]: + errors.append(f"{path}: referencePath is empty") + + # `ParsedField::reference_path` is documented as mutually exclusive with + # `literal_value`: one pins a constant, the other pins a value copied from the + # request. Carried together they are two different answers to "what is this + # field's value", and a consumer cannot honour both. Checking each key on its own + # let the pair through — no live case today, which is the point of adding it now. + if lv is not None and f.get("referencePath"): + errors.append( + f"{path}: carries both literalValue and referencePath, " + f"which pin the value to different things" + ) + + +def check_enum_ref(node, path, errors, seen_refs=None): + """An `enumRef` anywhere, not only on a `ParsedField`. + + Request-side and stanza attributes carry one too, keyed by `kind` rather than `type`, + so gating this on the field-type vocabulary let the pending marker — an empty + `variants`, which tells a consumer the attribute admits NO value — ship on those + surfaces unchecked. + """ + ref = node.get("enumRef") + if not isinstance(ref, dict): + return + if not ref.get("variants"): + errors.append(f"{path}: enumRef {ref.get('name')!r} has no variants") + return + # The same member-name rule the top-level catalog follows: JS keeps only the last + # repeated property, so two members under one name mean the reference no longer + # describes what the runtime validates against. + members = [ + v.get("name") + for v in ref["variants"] + if isinstance(v, dict) and isinstance(v.get("name"), str) + ] + repeated = sorted({m for m in members if members.count(m) > 1}) + if repeated: + errors.append(f"{path}: enumRef {ref.get('name')!r} defines {repeated} more than once") + + # `(module, name)` denotes ONE exported enum. `stanza_export` dedups references by it + # with the first table winning, so two fields carrying that identity with different + # variants get different constraints depending on traversal order. + if seen_refs is None: + return + ident = (ref.get("module"), ref.get("name")) + if not all(isinstance(x, str) for x in ident): + return + table = tuple( + (v.get("name"), repr(v.get("value"))) + for v in ref["variants"] + if isinstance(v, dict) + ) + prev = seen_refs.setdefault(ident, (table, path)) + if prev[0] != table: + errors.append( + f"{path}: enumRef {ident} disagrees with the table at {prev[1]}" + ) + + +def check_const_bytes(node, path, errors): + """A request-side `constBytes` pin: the same invariant as a response `literalValue` + on a bytes field, written differently. Both say "these exact bytes"; only one was + checked.""" + cb = node.get("constBytes") + if not isinstance(cb, str): + return + # It IS the fixed value of byte content. The emitter honours it whatever `kind` says, + # so a `dynamic` or `const` node carrying one hands two consumers two different + # answers depending on which field they dispatch on. + kind = node.get("kind") + if kind is not None and kind != "bytes": + errors.append(f"{path}: constBytes on {kind!r} content, which is not bytes") + if any(c not in "0123456789abcdef" for c in cb) or len(cb) % 2: + errors.append(f"{path}: constBytes {cb!r} is not lowercase hex") + elif "byteLength" in node and not ( + isinstance(node["byteLength"], int) and not isinstance(node["byteLength"], bool) + ): + errors.append(f"{path}: byteLength is {node['byteLength']!r}, not an integer") + elif "byteLength" in node and len(cb) != node["byteLength"] * 2: + errors.append( + f"{path}: constBytes is {len(cb) // 2} bytes, " + f"byteLength says {node['byteLength']}" + ) + + +def check_enum_catalog_refs(data, domain, errors): + """Enum references that point INTO the document's own top-level `enums` catalog. + + Every other enum in the IR inlines its values, so `check_enum_ref` — which looks at + an `enumRef` object in place — is enough. WAM instead names a module and expects the + consumer to find it in `enums`, and nothing verified the link resolved: a dangling + name, or a definition left with no variants (which the schema permits), still read as + "internally consistent" while a consumer could not encode the field at all. + + Keyed on the document HAVING a catalog rather than on the domain name, so a second + domain adopting the same shape is covered without anyone remembering to add it here. + """ + catalog = data.get("enums") + if not isinstance(catalog, list): + return + # An EMPTY catalog is not "nothing to check" — it is the worst case. If extraction + # collapsed the list while the enum-typed fields remained, every reference in the + # document is dangling, and returning early here reported exactly that document as + # internally consistent. An empty list is an empty lookup; the walk still runs. + by_module = {} + seen_defs = {} + for i, e in enumerate(catalog): + if not isinstance(e, dict): + continue + # Checked directly, not only through a reference: `generated/enums/` is a catalog + # nothing in the document points at, so validating references alone left every one + # of its 326 definitions unexamined. A named enum with no values is the same + # broken promise whether or not this document happens to cite it. + if not e.get("variants"): + errors.append( + f"{domain}/enums/{i}: enum {e.get('name')!r} is defined with no values" + ) + # `valueKind` is what tells a consumer how to represent these values; the schema + # permits any `Scalar` per variant, so it cannot check the two agree. A variant + # that disagrees would have the consumer pick an incompatible representation. + # `bool` is excluded explicitly — in Python it IS an int. + # One member name, one value. JS keeps only the last duplicate property, so a + # definition carrying both gives a consumer two contradictory answers for the same + # member — and `uniqueItems` cannot see it, because the objects differ in `value`. + members = [ + v.get("name") or v.get("key") + for v in e.get("variants") or [] + if isinstance(v, dict) and isinstance(v.get("name") or v.get("key"), str) + ] + repeated = sorted({m for m in members if members.count(m) > 1}) + if repeated: + errors.append( + f"{domain}/enums/{i}: enum {e.get('name')!r} defines " + f"{repeated} more than once" + ) + + expected = {"int": int, "string": str}.get(e.get("valueKind")) + if expected is not None: + for j, v in enumerate(e.get("variants") or []): + # A non-dict entry is malformed IR — exactly what this exists to catch — + # so it must be REPORTED, not crash the run on `.get`. The value guard + # below was written defensively; the message beside it was not. + if not isinstance(v, dict): + errors.append( + f"{domain}/enums/{i}: enum {e.get('name')!r} variant {j} " + f"is {type(v).__name__}, not an object" + ) + continue + val = v.get("value") + if not isinstance(val, expected) or isinstance(val, bool): + errors.append( + f"{domain}/enums/{i}: enum {e.get('name')!r} is valueKind " + f"{e.get('valueKind')!r} but variant " + f"{(v.get('name') or v.get('key'))!r} carries {val!r}" + ) + # WAM ONLY. There, integer members become discriminants of a `#[repr(i64)]` enum + # and a repeat is E0081. `generated/enums/` is emitted as an untyped `(name, + # value)` slice, where an upstream ALIAS — two names for one value — is legal and + # preserved; rejecting it would have failed CI on correct data the next time WA + # shipped one. The `repr` argument belongs to the WAM generator, not to both. + vals = [] if domain != "wam" else [ + v.get("value") + for v in e.get("variants") or [] + if isinstance(v, dict) + and isinstance(v.get("value"), int) + and not isinstance(v.get("value"), bool) + ] + dup_vals = sorted({v for v in vals if vals.count(v) > 1}) + if dup_vals: + errors.append( + f"{domain}/enums/{i}: enum {e.get('name')!r} reuses value(s) {dup_vals}" + ) + # `(module, name)` is the catalog identity — the extractor dedups on it, and a + # consumer has no other way to select one definition of a repeated pair. + ident = (e.get("module"), e.get("name")) + if not all(isinstance(part, str) for part in ident): + errors.append(f"{domain}/enums/{i}: identity {ident!r} is not two strings") + elif ident in seen_defs: + errors.append( + f"{domain}/enums/{i}: {ident} already defined at index {seen_defs[ident]}" + ) + else: + seen_defs[ident] = i + # Keyed only when it can BE a key: a dict or list `module` is unhashable and + # `setdefault` raises. The identity check above already reported it. + if isinstance(e.get("module"), str): + by_module.setdefault(e["module"], []).append(e) + + def visit(node, path): + if node.get("kind") != "enum" or "module" not in node: + return + module = node["module"] + # Hashable check on the REFERENCE side too, not just where definitions are + # indexed: `by_module.get` on a list or dict raises, and this is a reference + # walker over arbitrary JSON. + if not isinstance(module, str): + errors.append(f"{domain}{path}: enum reference module is {module!r}, not a string") + return + found = by_module.get(module, []) + if not found: + errors.append(f"{domain}{path}: enum reference {module!r} is in no definition") + elif len(found) > 1: + errors.append( + f"{domain}{path}: enum reference {module!r} matches " + f"{len(found)} definitions, so it does not identify one" + ) + elif not found[0].get("variants"): + errors.append(f"{domain}{path}: enum reference {module!r} resolves to no values") + + walk(data, visit) + + +def check_event_codes(data, domain, errors): + """A WAM event's `code` is its wire identifier, so two events cannot share one. + + The schema has no way to say "unique across the array". A consumer generating a + dispatch table by code would silently overwrite one of the pair, and one of the two + event shapes would then be unreachable — with nothing in the document indicating it. + """ + events = data.get("events") + if not isinstance(events, list): + return + seen = {} + seen_events = {} + for e in events: + if not isinstance(e, dict) or "code" not in e: + continue + code = e["code"] + ident = (e.get("module"), e.get("name")) + if all(isinstance(x, str) for x in ident): + if ident in seen_events: + errors.append( + f"{domain}: event identity {ident} already defined — JS keeps only " + f"the last property under that name" + ) + else: + seen_events[ident] = True + if code in seen: + errors.append( + f"{domain}: events {seen[code]!r} and {e.get('name')!r} " + f"share code {code}, which is the wire identifier" + ) + else: + seen[code] = e.get("name") + # `[default, ring1, ring2]` — the positions ARE the meaning, so a short array does + # not lose the last ring, it shifts every ring that remains. The schema permits any + # length. + # The wire format carries the code in 16 bits, and the reference consumer emits it + # as `u16`. The schema permits the whole `u32` range, and the extractor turns a + # negative source literal into a large one — either way the IR would be declared + # usable and then generate metadata that cannot encode. + if isinstance(code, int) and not 0 <= code <= 0xFFFF: + errors.append( + f"{domain}: event {e.get('name')!r} has code {code}, " + f"which does not fit the 16-bit wire field" + ) + # An unrecognised channel is not preserved by the reference generator — it maps + # to `Regular`, so a typo silently uploads on channel 0 instead of failing. + channel = e.get("channel") + if channel is not None and channel not in WAM_CHANNELS: + errors.append( + f"{domain}: event {e.get('name')!r} has channel {channel!r}, " + f"not one of {sorted(WAM_CHANNELS)}" + ) + # The WAM codec defines this global only for the private channel, so an id on a + # `regular`/`realtime` event is metadata that cannot be applied where the event + # says it is sent. + if e.get("privateStatsId") is not None and channel != "private": + errors.append( + f"{domain}: event {e.get('name')!r} declares a privateStatsId on the " + f"{channel!r} channel" + ) + weights = e.get("weights") + if not isinstance(weights, list) or len(weights) != 3: + errors.append( + f"{domain}: event {e.get('name')!r} carries " + f"{len(weights) if isinstance(weights, list) else 'no'} sampling " + f"weight(s), not the three positions [default, ring1, ring2]" + ) + # A field's `id` is its wire identifier WITHIN the event, so the same rule applies + # one level down: an encoder handed two fields sharing an id cannot tell them + # apart. Scoped per event — ids repeat across events by design. + by_id = {} + for f in e.get("fields") or []: + if not isinstance(f, dict) or "id" not in f: + continue + fid = f["id"] + if isinstance(fid, int) and not 0 <= fid <= 0xFFFF: + errors.append( + f"{domain}: in event {e.get('name')!r}, field {f.get('name')!r} has " + f"id {fid}, which does not fit the 16-bit wire field" + ) + if fid in by_id: + errors.append( + f"{domain}: in event {e.get('name')!r}, fields {by_id[fid]!r} and " + f"{f.get('name')!r} share id {fid}" + ) + else: + by_id[fid] = f.get("name") + # JS keeps only the LAST property of a repeated name, so two fields sharing one + # means the catalog no longer describes the runtime event — and the codegen + # silently invents a suffixed name rather than failing. + fnames = [ + f.get("name") + for f in e.get("fields") or [] + if isinstance(f, dict) and isinstance(f.get("name"), str) + ] + repeated = sorted({n for n in fnames if fnames.count(n) > 1}) + if repeated: + errors.append( + f"{domain}: event {e.get('name')!r} defines field(s) {repeated} twice" + ) + + +def check_action_keys(node, path, errors, flattened): + """`fields`, `constantFields` and `children` are three representations of ONE object + key namespace, so a name may appear in only one of them. + + Runtime cannot produce two values for one key, and a consumer handed both a wire-read + `reason` and a constant `reason` has a shape that contradicts itself. Each array is + internally consistent by construction; nothing compared them to each other. + """ + arrays = [k for k in ("fields", "constantFields", "children") if isinstance(node.get(k), list)] + # Two questions, two answers. ACROSS arrays a repeat is a contradiction and fails. + # WITHIN one array it is the extractor FLATTENING a discriminated shape — ten `value` + # fields in `privacyParser`, one per ``, with the discriminator and + # the output names dropped — so it is a known loss held to a baseline, not an error. + # + # It was briefly checked as an error, found these 15, and switched off as "alternatives + # modelled on purpose". Reading the source showed that was wrong. Recording the loss is + # what this file is for; silently certifying it is what it exists to prevent. + for key in arrays: + names_in = [ + it["name"] for it in node[key] if isinstance(it, dict) and "name" in it + ] + extra = len(names_in) - len(set(names_in)) + if extra: + # By IDENTITY, not by total: one loss disappearing while another appears + # elsewhere kept the aggregate at 42 and passed — the third time this exact + # blind spot has been found in this file, twice in baselines I wrote. + repeated = sorted({n for n in names_in if names_in.count(n) > 1}) + flattened[f"{path}/{key}|{','.join(repeated)}"] += extra + + if len(arrays) < 2: + return + names = [ + it["name"] + for k in arrays + for it in node[k] + if isinstance(it, dict) and "name" in it + ] + repeated = sorted({n for n in names if names.count(n) > 1}) + if repeated: + errors.append(f"{path}: one key filled twice across fields/constantFields/children: {repeated}") + + +def check_abprops(data, domain, errors): + """An A/B config's `default`/`altDefault` are typed by its `valueType`. + + `Scalar` permits every arm and nothing compared the two, so `valueType: "bool"` with a + string default validated clean and the reference generator would emit contradictory + `AbPropType::Bool` / `AbDefault::Str` metadata. + """ + configs = data.get("configs") + if not isinstance(configs, list): + return + seen_ids = {} + seen_codes = {} + for i, c in enumerate(configs): + if not isinstance(c, dict): + continue + # `(module, name)` IS the flag identity. Two records under one identity leave a + # consumer no way to pick; the reference generator just suffixes the second and + # puts both contradictory entries in `ALL`. + # The `code` is what the `` IQ sends, so two flags sharing one inside a + # module leave a consumer unable to associate a returned value with either. + code_key = (c.get("module"), c.get("code")) + if all(isinstance(part, (str, int)) and not isinstance(part, bool) for part in code_key): + if code_key in seen_codes: + errors.append( + f"{domain}/configs/{i}: code {c.get('code')!r} in " + f"{c.get('module')!r} already used at index {seen_codes[code_key]}" + ) + else: + seen_codes[code_key] = i + ident = (c.get("module"), c.get("name")) + if not all(isinstance(part, str) for part in ident): + errors.append(f"{domain}/configs/{i}: identity {ident!r} is not two strings") + elif ident in seen_ids: + errors.append( + f"{domain}/configs/{i}: {ident} already defined at index {seen_ids[ident]}" + ) + else: + seen_ids[ident] = i + vt = c.get("valueType") + for key in ("default", "altDefault"): + if key not in c: + continue + v = c[key] + # `bool` first: in Python it IS an int, so an unordered check would let `True` + # satisfy `valueType: "int"`. + if vt == "bool": + ok = isinstance(v, bool) + elif vt == "int": + ok = isinstance(v, int) and not isinstance(v, bool) + elif vt == "float": + # A bare integer is not a float literal: the generator emits the value as + # written, so `AbDefault::Float` would carry an int and a consumer reading + # the declared type gets a mismatch the schema cannot see. + ok = isinstance(v, float) + elif vt == "string": + ok = isinstance(v, str) + else: + continue + if not ok: + errors.append( + f"{domain}/configs/{i}: {c.get('name')!r} is valueType {vt!r} " + f"but its {key} is {v!r}" + ) + + +def pascal_case(name): + """A faithful port of `wa_codegen::naming::pascal_case`, which is what actually names + the generated variants. + + Two approximations preceded this and both certified pairs that cannot compile: the + first split only on `-`/`_`, missing `fooBar` vs `foo-bar`; the second still missed + `foo.bar`, because the generator replaces EVERY non-alphanumeric, not three chosen + ones. It also uppercases the first character without lowering the rest, which + `.capitalize()` does not do. + """ + s2 = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name) + s2 = re.sub(r"([A-Z])([A-Z][a-z])", r"\1 \2", s2) + s2 = re.sub(r"[^a-zA-Z0-9]", " ", s2) + return "".join(w[:1].upper() + w[1:] for w in s2.split() if w) + + +def check_notif_identifiers(data, domain, errors): + """Notification types and stanza tags become generated Rust identifiers. + + `emit_notification_type_enum` and `StanzaTag` run each through `pascal_case`, so two + wire spellings that normalise alike emit one variant — and one match arm — twice. + Same rule already applied to appstate collections, scopes and union alternatives. + """ + for key, label in (("notifications", "type"), ("stanzaTags", "tag")): + items = data.get(key) + if not isinstance(items, list): + continue + by_variant = {} + for it in items: + raw = it.get(label) if label and isinstance(it, dict) else it + if not isinstance(raw, str): + continue + variant = pascal_case(raw) + prev = by_variant.setdefault(variant, raw) + if prev != raw: + errors.append( + f"{domain}/{key}: {raw!r} and {prev!r} both become {variant!r}" + ) + + +def check_tokens(data, domain, errors): + """`singleByte[tag]` and `doubleByte[dict][index]` are direct lookups by a wire BYTE. + + Index zero of the single-byte table is reserved: a table that lost its leading empty + string shifts every token by one, so the consumer encodes and decodes different + strings than the wire carries — silently, and for everything. And a position past 255 + is unaddressable, so publishing it advertises a token that can be neither encoded nor + decoded. + """ + table = data.get("singleByte") + if isinstance(table, list): + if not table: + errors.append(f"{domain}/singleByte: the table is empty") + else: + if table[0] != "": + errors.append( + f"{domain}/singleByte[0] is {table[0]!r}; index zero is the reserved slot" + ) + if len(table) > 256: + errors.append( + f"{domain}/singleByte holds {len(table)} tokens; a one-byte tag " + f"addresses at most 256" + ) + dicts = data.get("doubleByte") + if isinstance(dicts, list): + # FOUR, not 256: the wire exposes `DICTIONARY_0`..`DICTIONARY_3` and nothing + # else, so entries 4 and up have no selector tag and are unreachable. + if len(dicts) > 4: + errors.append( + f"{domain}/doubleByte has {len(dicts)} dictionaries; the wire has four " + f"selector tags" + ) + for i, sub in enumerate(dicts): + if isinstance(sub, list) and len(sub) > 256: + errors.append( + f"{domain}/doubleByte/{i} holds {len(sub)} tokens; the index is one byte" + ) + + +def check_appstate_collections(data, domain, errors, proto_enums, proto_messages, sync_fields): + """An action's `collection` must be one the document declares. + + The schema is only a string. `generate_appstate_schemas` builds the `Collection` enum + from the top-level list alone, so a name absent from it emits a variant that does not + exist — the certified IR generates uncompilable Rust. An omitted field means `regular`, + which must therefore be declared too. + """ + declared = data.get("collections") + actions = data.get("actions") + if not isinstance(declared, list) or not isinstance(actions, dict): + return + names = [c for c in declared if isinstance(c, str)] + # Exact repeats first: the set below collapses them, so checking after it would never + # see a pair that `render_collection_enum` happily emits twice. + exact = sorted({c for c in names if names.count(c) > 1}) + if exact: + errors.append(f"{domain}/collections: {exact} listed more than once") + known = set(names) + # `render_collection_enum` runs each through `pascal_case` without deduplicating, so + # two wire names that normalise alike emit the same variant twice and will not compile. + by_variant = {} + for c in sorted(known): + variant = pascal_case(c) + if variant in by_variant: + errors.append( + f"{domain}/collections: {c!r} and {by_variant[variant]!r} both become " + f"{variant!r}" + ) + else: + by_variant[variant] = c + # Action SCOPES become a generated enum the same way collections do, through the same + # `pascal_case` — so two spellings that normalise alike emit the variant twice. + by_scope = {} + for name, a in sorted(actions.items()): + if not isinstance(a, dict) or not isinstance(a.get("scope"), str): + continue + variant = pascal_case(a["scope"]) + prev = by_scope.setdefault(variant, a["scope"]) + if prev != a["scope"]: + errors.append( + f"{domain}/actions/{name}: scopes {a['scope']!r} and {prev!r} both " + f"become {variant!r}" + ) + + for name, a in sorted(actions.items()): + if not isinstance(a, dict): + continue + used = a.get("collection", "regular") + # Type-checked before the membership test. `known` holds strings; an unhashable + # value (a list, a dict) makes `in` raise and takes the whole run down. Fourth time + # this class has come up here, and it is always the error path, never the check. + if not isinstance(used, str): + errors.append(f"{domain}/actions/{name}: collection is {used!r}, not a string") + elif used not in known: + errors.append( + f"{domain}/actions/{name}: collection {used!r} is not among " + f"{sorted(known)}" + ) + # Position zero of the sync index IS the wire action name. Anything else has the + # encoder identify the action by one name and build its index under another. + parts = a.get("indexParts") + first = parts[0] if isinstance(parts, list) and parts else None + # `valueProtoType` names the `SyncActionValue` payload message. A typo or a + # removed message leaves mutation tooling unable to build what the IR declares, + # and the generator publishes the string unchanged. + vpt = a.get("valueProtoType") + if isinstance(vpt, str) and vpt not in proto_messages: + errors.append( + f"{domain}/actions/{name}: valueProtoType {vpt!r} is in no committed " + f"protobuf message" + ) + # The FIELD too, and that the two agree: a consumer places the message into the + # `SyncActionValue` field this names, so a wrong name — or a real name typed as a + # different message — leaves the payload unbuildable. Checking only that the + # message exists somewhere left `valueField` entirely unverified. + vf = a.get("valueField") + if isinstance(vf, str) and isinstance(vpt, str): + declared = sync_fields.get(vf) + if declared is None: + errors.append( + f"{domain}/actions/{name}: valueField {vf!r} is not a field of " + f"SyncActionValue" + ) + elif declared.split(".")[-1] != vpt.split(".")[-1]: + errors.append( + f"{domain}/actions/{name}: valueField {vf!r} is declared " + f"{declared!r}, not {vpt!r}" + ) + # Each `valueEnumFields` value is a protobuf enum PATH, published unchanged for a + # consumer to interpret. A typo or a removed enum leaves mutation tooling unable + # to resolve what the IR claims — and the schema is only a string. + vef = a.get("valueEnumFields") + if vef is not None and not isinstance(vef, dict): + errors.append(f"{domain}/actions/{name}: valueEnumFields is {vef!r}, not a map") + vef = None + for field, path_ in sorted((vef or {}).items()): + if isinstance(path_, str) and path_ not in proto_enums: + errors.append( + f"{domain}/actions/{name}: valueEnumFields[{field!r}] names " + f"{path_!r}, which is in no committed protobuf enum" + ) + # `chatJidIndex` is the POSITION holding the chat JID, passed through unchanged to + # the encoder — so an out-of-range one indexes past `indexParts`, and one pointing + # at a non-JID part encodes the wrong component. `null` means "no chat JID", which + # 47 of the 67 actions legitimately are. + cji = a.get("chatJidIndex") + if cji is not None: + parts_list = parts if isinstance(parts, list) else [] + if not isinstance(cji, int) or isinstance(cji, bool) or not 0 <= cji < len(parts_list): + errors.append( + f"{domain}/actions/{name}: chatJidIndex {cji!r} is not a position in " + f"its {len(parts_list)} index part(s)" + ) + elif not ( + isinstance(parts_list[cji], dict) and parts_list[cji].get("type") == "jid" + ): + errors.append( + f"{domain}/actions/{name}: chatJidIndex {cji} points at a " + f"{parts_list[cji].get('type') if isinstance(parts_list[cji], dict) else parts_list[cji]!r} part, not a jid" + ) + if not isinstance(first, dict) or first.get("type") != "literal": + errors.append( + f"{domain}/actions/{name}: indexParts does not begin with a literal" + ) + elif first.get("value") != a.get("name"): + errors.append( + f"{domain}/actions/{name}: index begins with {first.get('value')!r}, " + f"not the action name {a.get('name')!r}" + ) + + +def check_child_requiredness(node, path, errors): + """Every `NotifActionChild` must SAY whether it is always present. + + Rust reads a missing `required` as `true` through a serde default, but the schema + cannot carry that: the default makes schemars drop the property from its `required` + list, so an omission validates clean and a non-Rust consumer — who the IR is for — + cannot tell "required by default" from "unspecified". + + Driven from the `children` ARRAY. Keying on the child's own `fields` missed a child + that has none, which `serde(skip_serializing_if = "Vec::is_empty")` makes a perfectly + ordinary serialized shape. + """ + children = node.get("children") + if not isinstance(children, list): + return + for i, c in enumerate(children): + if isinstance(c, dict) and "wireTag" in c and "required" not in c: + errors.append( + f"{path}/children/{i}: mapped child does not say whether it is required" + ) + + +def check_variant_groups(node, path, errors): + """Each `WapVariantGroup` says exactly one of its alternatives applies. + + With none listed, a REQUIRED group promises a choice and supplies nothing to choose: + an emitter cannot construct the request, and the generator emits a required field + whose enum has no variants. An optional group with no alternatives is merely empty. + + Driven from the `variantGroups` ARRAY rather than a group's own keys. `optional` is + skipped when false, so a required group never carries it — and the first version of + this, gated on that key being present, never ran on the very groups it was for. + """ + groups = node.get("variantGroups") + if not isinstance(groups, list): + return + for i, g in enumerate(groups): + if not isinstance(g, dict): + errors.append(f"{path}/variantGroups/{i}: not an object") + elif not g.get("optional") and not g.get("variants"): + errors.append( + f"{path}/variantGroups/{i}: required variant group offers no alternative" + ) + + +def check_assertion(a, path, errors): + if a.get("kind") == "reference": + if not a.get("referencePath"): + errors.append(f"{path}: reference assertion with no referencePath") + # The path says WHERE in the request the value comes from; `name` says which + # response attribute has to echo it. With only the path a consumer knows the + # source of a rule it cannot apply to anything. + if not a.get("name"): + errors.append(f"{path}: reference assertion with no target attribute name") + # Its expected value comes FROM THE REQUEST, so it has none of its own. The + # reference codegen ignores `value`; another consumer could enforce it, and the + # two would disagree about the same response rule. + if a.get("value") is not None: + errors.append(f"{path}: reference assertion also pins a fixed value") + if a.get("kind") == "attr" and not a.get("name"): + errors.append(f"{path}: attr assertion with no attribute name") + # `WapAttrKind::Const` is documented as "fixed literal value (carried in + # `WapAttrDef::value`)", but `value` is optional in the schema. Without it the IR + # tells an emitter the value is fixed and then declines to say to what. + # + # Only this direction. `value` is NOT exclusive to const: `kind` is shared with the + # assertion vocabulary, where `attr` and `content` carry one legitimately (1781 nodes + # today), so rejecting `value` on non-const kinds would flag correct documents. + if a.get("kind") == "const" and a.get("value") is None: + errors.append(f"{path}: const attribute with no value") + # A `content` assertion IS the fixed text a marker union variant matches on. Without + # the value a consumer cannot tell when the variant applies. Narrow on purpose: an + # `attr` assertion may legitimately assert only presence. + if a.get("kind") == "content" and a.get("value") is None: + errors.append(f"{path}: content assertion with no value to match") + # A `tag` assertion IS the expected tag; the incoming and server-request scanners read + # it from `name`. Without it the assertion can neither enforce nor dispatch a shape. + if a.get("kind") == "tag" and not a.get("name"): + errors.append(f"{path}: tag assertion with no tag name") + + +def main() -> int: + root = Path(sys.argv[1] if len(sys.argv) > 1 else "generated") + errors: list[str] = [] + counts = dict.fromkeys(BASELINE, 0) + unresolved: dict[str, int] = defaultdict(int) + flattened: dict[str, int] = defaultdict(int) + seen_refs: dict[tuple, tuple] = {} + + # The protobuf enums an appstate `protoEnum` may name. A path that resolves to + # nothing is not a constraint — it only looks like one because the string is present, + # and the schema accepts any string. + proto_enums = collect_proto_enums(root / "proto" / "WAProto.proto") + proto_messages = collect_proto_messages(root / "proto" / "WAProto.proto") + sync_fields = collect_sync_action_fields(root / "proto" / "WAProto.proto") + + docs = sorted(root.glob("*/index.json")) + if not docs: + sys.exit(f"no domain documents under {root}") + + for doc in docs: + try: + data = json.loads(doc.read_text()) + except (OSError, json.JSONDecodeError) as e: + # Reported, not raised: an unreadable document is exactly the malformed input + # this exists to report, and a traceback tells a CI reader far less than a + # line naming the file. + errors.append(f"{doc}: cannot be read as JSON ({e})") + continue + domain = doc.parent.name + + def visit(node, path, domain=domain): + # A field is anything with a KNOWN type, or anything carrying a key only a + # response field has. The first clause reaches the appstate fields, which + # carry no `method`/`wireName`; the second is what makes an unrecognized type + # reportable rather than invisible — keying on the vocabulary alone meant a + # typo in `type` silently excused the field from every other check. + # A marker only counts when the object HAS a type: an assertion carries + # `referencePath` too, and flagging those as untyped fields was the first + # version of this check reporting four contradictions that were not. + known = node.get("type") in FIELD_TYPES + marked = "type" in node and any(k in node for k in FIELD_MARKERS) + if known or marked: + if not known: + errors.append( + f"{domain}{path}: field type {node.get('type')!r} is not in the " + f"ParsedFieldType vocabulary" + ) + check_field( + node, f"{domain}{path}", domain, errors, counts, proto_enums + ) + # No `reference_path` guard: the IR emits `referencePath`, so that condition + # excluded nothing (13138 of 13138 nodes passed it) and only read as though it + # did. Every `kind` value these checks name — `reference`, `attr`, `tag`, + # `content`, `const` — belongs to exactly one of the two vocabularies, so + # running them on any node carrying a `kind` is correct as well as honest. + if "kind" in node: + check_assertion(node, f"{domain}{path}", errors) + # Independent of the field gate — see each function's note. + check_enum_ref(node, f"{domain}{path}", errors, seen_refs) + check_const_bytes(node, f"{domain}{path}", errors) + check_action_keys(node, f"{domain}{path}", errors, flattened) + check_variant_groups(node, f"{domain}{path}", errors) + check_child_requiredness(node, f"{domain}{path}", errors) + + walk(data, visit) + # Needs the whole document, not one node: the reference and its definition sit in + # different subtrees. + check_enum_catalog_refs(data, domain, errors) + check_event_codes(data, domain, errors) + check_abprops(data, domain, errors) + check_appstate_collections( + data, domain, errors, proto_enums, proto_messages, sync_fields + ) + check_tokens(data, domain, errors) + check_notif_identifiers(data, domain, errors) + collect_unresolved_enums(data, domain, proto_enums, unresolved) + + ok = True + # Set difference in BOTH directions, plus the multiplicities. A gain that offsets a + # loss keeps the total at 155 and is exactly what a scalar cannot see. + # COUNTS, not just membership: the semantic trail omits array indices for reorder + # stability, so two sibling fields can share an identity — and with a set, one of them + # losing its `enumRef` left the set unchanged and passed. + for ident in sorted(set(flattened) | set(FLATTENED_KEYS)): + now, was = flattened.get(ident, 0), FLATTENED_KEYS.get(ident, 0) + if now == was: + continue + ok = False + if was == 0: + print(f"REGRESSION newly flattened shape: {ident} (x{now})") + elif now == 0: + print(f"IMPROVED shape no longer flattened: {ident} — drop it") + else: + print(f"CHANGED {ident}: {was} -> {now}") + + for ident in sorted(set(unresolved) | set(UNRESOLVED_ENUMS)): + now, was = unresolved.get(ident, 0), UNRESOLVED_ENUMS.get(ident, 0) + if now == was: + continue + ok = False + if was == 0: + print(f"REGRESSION newly unresolved enum: {ident} (x{now})") + elif now == 0: + print(f"IMPROVED enum now resolved: {ident} — drop it from the baseline") + else: + print(f"CHANGED {ident}: {was} -> {now} — update the baseline") + if ok: + print( + f"ok unresolved enums: {sum(unresolved.values())} across " + f"{len(unresolved)} identities, exactly as pinned" + ) + + for name, observed in sorted(counts.items()): + allowed = BASELINE[name] + if observed > allowed: + print(f"REGRESSION {name}: {observed} (baseline {allowed})") + ok = False + elif observed < allowed: + # A RATCHET, not an upper bound. Accepting a decrease silently banks the + # difference as slack: 157 -> 150 followed by seven newly unresolved enums is + # back at 157 and passes, which is exactly the drift this is supposed to catch. + # An improvement is real work and updating the number with it costs one line. + print(f"IMPROVED {name}: {observed} (baseline {allowed}) — lower the baseline") + ok = False + else: + print(f"ok {name}: {observed} (baseline {allowed})") + + for e in errors: + print(f"ERROR {e}") + + if errors: + print(f"\n{len(errors)} internal contradiction(s) in the IR") + return 1 + if not ok: + print( + "\na counted state left its baseline — raise means a constraint is being lost, " + "fall means the baseline owes an update" + ) + return 1 + print(f"\n{len(docs)} document(s) internally consistent") + return 0 + + +if __name__ == "__main__": + sys.exit(main())