Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ced8322
ir: extract the validation constraints WA Web enforces, not just fiel…
jlucaso1 Jul 27, 2026
5500115
scan: enforce that a reference echo reads the request, not just any node
jlucaso1 Jul 27, 2026
08c01ed
review: fix the constraint-layer defects found in review
jlucaso1 Jul 27, 2026
28b8b14
review: pair the error vocabulary, and fix the losses round 2 surfaced
jlucaso1 Jul 27, 2026
bd585b6
review: inherit outer error pins, and fix the accessor/scope gaps rou…
jlucaso1 Jul 27, 2026
1bf091c
review: per-return scopes, and call the schema bump what it is (2.0.0)
jlucaso1 Jul 27, 2026
f71f94a
review: branch-merge value-position helpers, and stop `||` weakening …
jlucaso1 Jul 27, 2026
3552d0d
review: classify wire errors by the root discriminator, merge mapped-…
jlucaso1 Jul 27, 2026
f529bd7
review: drop phantom actions, model the two-level error, and stop ove…
jlucaso1 Jul 27, 2026
5830753
ir: model the error vocabulary correctly, breaking 1.x rather than wo…
jlucaso1 Jul 27, 2026
058a959
review: place presence-flag pins on their node, and stop three ambigu…
jlucaso1 Jul 27, 2026
632eb30
wap: derive `maybe*` accessor types instead of enumerating them
jlucaso1 Jul 27, 2026
90cd7d6
docs: note the accessor-type classification in the constraints section
jlucaso1 Jul 27, 2026
accb9b9
review: refuse conflicting field sources, and key drops per parser site
jlucaso1 Jul 27, 2026
768a453
wap: derive the attr-accessor predicate too, and give JID reads their…
jlucaso1 Jul 28, 2026
07ddaa2
review: end the fall-through chain at a break, and honour last-write …
jlucaso1 Jul 28, 2026
2ab37e2
notif: one implementation of the branch-merge rule; one accessor clas…
jlucaso1 Jul 28, 2026
b02e251
scan: resolve response enums on the legacy path too; last-write for e…
jlucaso1 Jul 28, 2026
770e801
scan: type every content accessor, and stop the pending-enum sentinel…
jlucaso1 Jul 28, 2026
b111ce0
scan: count what the non-IQ domains lose, and fix the paths round 15 …
jlucaso1 Jul 28, 2026
f7458c1
codegen: stop dropping the content fields the scanners recover
jlucaso1 Jul 28, 2026
891c9a3
codegen: ask the classifier for the bytes branch too
jlucaso1 Jul 28, 2026
7219c32
notif: read the error paths of a try, and let a try terminate a case
jlucaso1 Jul 28, 2026
42c93c5
notif: decide the finally override by control flow, and refuse two-so…
jlucaso1 Jul 28, 2026
21815bd
codegen: decode contentUint as big-endian bytes, not decimal text
jlucaso1 Jul 28, 2026
2ec36a9
scan: honour type="result", and stop counting losses the artifact nev…
jlucaso1 Jul 28, 2026
5f10088
scan: keep content enums reading content, and stop a rejected module …
jlucaso1 Jul 28, 2026
cb533c8
notif: recover the fields a value-normalising helper was eating
jlucaso1 Jul 28, 2026
276b255
notif: emit the payload action unions in the reference codegen
jlucaso1 Jul 28, 2026
e2e01b4
notif: keep field and constant types in the generated action table
jlucaso1 Jul 28, 2026
a841908
scan: sort the fallback candidates, and fix three scope rules the blo…
jlucaso1 Jul 28, 2026
2c09ff9
scan: count the two enum/marker constraints that were vanishing silently
jlucaso1 Jul 28, 2026
25ad520
scan: stop two invented constraints, and pick the response root by st…
jlucaso1 Jul 28, 2026
37698d9
codegen: derive optionality from the IR, not only from the accessor name
jlucaso1 Jul 28, 2026
c4d4c76
notif: bind helper parameters, and stop three shapes the extractor wa…
jlucaso1 Jul 28, 2026
93ae213
notif: drop a redundant closure clippy flagged
jlucaso1 Jul 28, 2026
bca400b
notif: five more JS-semantics corrections in the action reader
jlucaso1 Jul 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,26 @@ WhatsApp Web ships its whole protocol (IQ stanzas, protobuf schemas, GraphQL ope
| **appstate** | `appstate/index.json` (+ Rust) | app-state (syncd) action schemas + indexing |
| **abprops** | `abprops/index.json` (+ Rust) | the A/B-props feature-flag registry (~1.7k flags: code, type, default) |
| **enums** | `enums/index.json` (+ Rust) | the wire-enum catalog (nack codes, chat/receipt types, …) |
| **notif** | `notif/index.json` (+ Rust) | the incoming stanza-dispatch catalog: `<notification type="…">` kinds + handlers + typed content shapes |
| **notif** | `notif/index.json` (+ Rust) | the incoming stanza-dispatch catalog: `<notification type="…">` kinds + handlers + typed content shapes + the payload **action unions** (`w:gp2`'s 40+ group actions) |
| **tokens** | `tokens/index.json` (+ `tokens.json`) | the binary-protocol token dictionaries (single-byte + 4 double-byte), wire-indexable |
| **wasm** | `wasm/index.json` | the WebAssembly surface: emscripten payload names (`liboqs_wasm_wrapper.wasm`, …) + the bootloader (`bx`) handles the JS resolves their bytes through, with consuming modules |

Every domain ships a JSON Schema under `generated/schema/`, and a top-level `generated/manifest.json` stamps the WhatsApp version, per-domain counts, content hashes, and extraction diagnostics.

### Validation constraints, not just field shapes

Field shapes tell you how to *read* a stanza. They are not enough to *produce* one, nor to explain why a real client rejected the one you produced. So the IR also carries the rules WA Web's own parsers enforce — symmetric by construction, so they serve a parser and an emitter equally:

- **Echo rules** (`assertions[].kind == "reference"`, and `referencePath` on a field) — an answer's `from` must equal the **request's** `to`, its `id` the request's `id`. `referencePath` is the argument list of WA's `attrStringFromReference`, so `["to"]` means "the request's `to`" and `["account","action"]` means "the `action` attribute of the request's `<account>` child" — no name-matching required. An emitter that hardcodes `from="s.whatsapp.net"` makes every answer to a `g.us` request unparseable.
- **Pinned values** (`literalValue` on a field) — `type="admin"` on a successful promote, `matched="true"`/`"false"` on a blocklist update, `code=429` on a rate-limit error. `required` separates the two forms: a required pin is a hard discriminator (must be present and exact); an optional one is pinned only when present (may be omitted, must never be contradicted).
- **The per-RPC error vocabulary** (`errorClass`, `errorCodes`, `errorTexts`, `errorCodeMin`/`Max` on a response variant) — a **closed** set, and it differs per RPC: `BatchGetGroupInfo` accepts `400 bad-request` and `429 rate-overlimit` and **rejects `404 item-not-found`**, even though that mixin exists and other RPCs use it. `errorCodeMin`/`Max` cover the open-ended fallback arms (any text, code in 400–499 / 500–599).
- **Response enums** (`enumRef` on a field) — the legal values behind an `attrStringEnum`, resolved the same way the request side already resolves them, instead of a bare `"type": "enum"`.
- **Notification action unions** (`notifications[].actions`) — the payload inside the envelope. The `wireTag → actionType` mapping is **many-to-one** (`not_ephemeral` normalises into `ephemeral` with `duration: 0`, so branching on `not_ephemeral` is dead code) and field names are rebound (the disappearing-message timer arrives in `expiration`, but the action field is `duration`). Neither is derivable from the wire.

Note the contract version: this raised `schemaVersion` to **2.0.0**. Nearly every addition is optional and a 1.x consumer ignores it, but `AssertionKind` gained a `reference` variant, which widens the value space of an existing field — a consumer with a closed enum (generated from the 1.0 schema, or the Rust enum) rejects the document rather than ignoring it. The migration is one variant wide: handle, or ignore, `kind: "reference"` on response assertions.

Anything the extractor sees but cannot resolve structurally is counted under `manifest.diagnostics.iq.dropsByReason` rather than omitted, so "no constraint here" and "a constraint we failed to extract" never look alike. `manifest.diagnostics.iq.constraints` and `diagnostics.notif.actions` are floor-guarded: a WA refactor that hides one of these constructs fails the update instead of silently emptying a field.

## Quick start

```sh
Expand All @@ -35,7 +49,8 @@ cargo run --release -p whatspec -- update --cache .wa-cache
# Seed that same cache from the lock-pinned GitHub Release (no live bundle fetch):
cargo run --release -p whatspec -- restore --from-lock generated/bundles.lock.json --cache .wa-cache

# Also resolve, download and store the client's wasm payloads (~41 MB), as <url-hash>.wasm:
# Also resolve, download and store the client's wasm payloads (~41 MB), as <url-hash>.wasm
# (a cache filename is a location label, not a content address — see the note below):
cargo run --release -p whatspec -- update --cache .wa-cache --wasm-out ./wasm

# Restore a locked wasm set (its own lock + its own content-addressed release asset):
Expand All @@ -56,6 +71,7 @@ WhatsApp only serves the *current* bundle version — old bundle URLs 404 — so

- **`generated/bundles.lock.json`** records the content SHA-256 (+ size, and origin URL when known) of every bundle that produced the committed `generated/`, plus a one-line, order-invariant `setHash` fingerprint of the whole set.
- The **bytes** live in a durable, WhatsApp-independent store: a rolling **`bundle-store`** GitHub Release whose assets are **content-addressed** — `bundles-<version>-<setHash>.tar.xz` (pure-Rust xz, roughly half the size of gzip; legacy `.tar.gz` assets are still read) — so a set is never overwritten with different bytes and every past commit's lock keeps resolving the exact archive it pins. Published automatically by the update workflow.
Note the two different hashes: a **release asset** is named after its *contents* (`setHash`), while a **cache** filename is named after its *URL* (the JS last segment, or `sha256(url)` for wasm) — there, the content hash lives in the cache's `manifest.json` and is what integrity is verified against. A cache filename never proves what is inside the file.
- **`whatspec restore --from-lock generated/bundles.lock.json --out <dir>`** pulls that archive, verifies every bundle's SHA-256 against the lock, and writes a directory ready for `update --bundles`. Use **`--cache <dir>`** instead to seed the exact version directly into the reusable `update --cache` layout; cache metadata and integrity files are written by the same `BundleCache` implementation as a live fetch. **`scripts/regen.sh`** wraps restore + `update --check` into a one-shot, offline determinism check — also run in CI, so every commit's committed IR (each `index.json` + `WAProto.proto`) is proven reproducible from its pinned inputs.

> Bootstrap: the lock and the first archive are created by the first run of the update workflow (or a manual `update --save-bundles <dir>` followed by `scripts/publish-bundles.sh <dir>`). Until then the CI reproducibility gate stays dormant.
Expand Down
3 changes: 3 additions & 0 deletions crates/wa-codegen/src/notif_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ mod tests {
kind: AssertionKind::Tag,
name: Some("notification".into()),
value: None,
reference_path: None,
}],
fields: vec![ParsedField {
method: "attrString".into(),
Expand All @@ -305,6 +306,7 @@ mod tests {
}],
..Default::default()
}),
actions: Vec::new(),
},
NotificationDef {
notif_type: "encrypt".into(),
Expand All @@ -319,6 +321,7 @@ mod tests {
}],
}],
content: None,
actions: Vec::new(),
},
],
}
Expand Down
14 changes: 13 additions & 1 deletion crates/wa-codegen/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,10 @@ fn emit_success_guards(op: &IqStanzaDef, indent: &str) -> Vec<String> {
}
}
// Tag (the `<iq>` root) / FromServer are not success-vs-error discriminators.
AssertionKind::Tag | AssertionKind::FromServer => {}
// Neither is a Reference echo (`from` == the request's `to`): every outcome
// of the same request satisfies it identically, so it separates nothing —
// it is a request-correlation rule, enforced by whoever holds the request.
AssertionKind::Tag | AssertionKind::FromServer | AssertionKind::Reference => {}
Comment on lines 349 to +353

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Enforce reference assertions in generated response parsers

When a response has the newly recovered echo constraints (id equal to the request's id, or from equal to its to), the generated IqSpec::parse_response discards them here and emits neither a check nor metadata through which its caller can apply one. A correctly shaped response carrying another request's id or target therefore passes the reference parser despite violating the parser contract represented in the IR; emit a common request-correlation guard outside the success/error variant dispatch.

Useful? React with 👍 / 👎.

}
}
lines
Expand Down Expand Up @@ -836,8 +839,10 @@ mod tests {
kind: AssertionKind::Attr,
name: Some("type".into()),
value: Some("result".into()),
reference_path: None,
}],
fields: vec![],
..Default::default()
}];
let guards = emit_success_guards(&s, " ");
assert_eq!(guards.len(), 1);
Expand Down Expand Up @@ -874,13 +879,15 @@ mod tests {
kind: ResponseVariantKind::Success,
assertions: vec![],
fields: vec![attr("token")],
..Default::default()
},
ResponseVariant {
tag: "GetThingResponseError".into(),
module_name: "m".into(),
kind: ResponseVariantKind::Error,
assertions: vec![],
fields: vec![attr("code")],
..Default::default()
},
],
..Default::default()
Expand Down Expand Up @@ -930,6 +937,7 @@ mod tests {
kind: AssertionKind::Attr,
name: Some("type".into()),
value: Some(value.into()),
reference_path: None,
}
}
// Success and error read the same field (`type`) — a bare subset that WOULD be
Expand All @@ -945,13 +953,15 @@ mod tests {
kind: ResponseVariantKind::Success,
assertions: vec![type_assert("result")],
fields: vec![attr("type")],
..Default::default()
},
ResponseVariant {
tag: "GetThingResponseError".into(),
module_name: "m".into(),
kind: ResponseVariantKind::Error,
assertions: vec![type_assert("error")],
fields: vec![attr("type")],
..Default::default()
},
],
..Default::default()
Expand Down Expand Up @@ -1000,13 +1010,15 @@ mod tests {
kind: ResponseVariantKind::Success,
assertions: vec![],
fields: vec![attr("type")],
..Default::default()
},
ResponseVariant {
tag: "GetThingResponseError".into(),
module_name: "m".into(),
kind: ResponseVariantKind::Error,
assertions: vec![],
fields: vec![attr("type")],
..Default::default()
},
],
..Default::default()
Expand Down
8 changes: 8 additions & 0 deletions crates/wa-fetch/src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
//! …
//! ```
//!
//! The cache is **not** content-addressed, unlike the durable release store it can be
//! seeded from (`bundles-<version>-<setHash>.tar.xz`). A cache filename is a *location*
//! label — the JS name comes from the URL's last segment, the wasm name is
//! `sha256(url)` — while the `sha256` recorded in `manifest.json` is the hash of the
//! **bytes**, and that is what integrity is checked against. Two hashes of different
//! things sit side by side here, so: the name never proves the content, the manifest
//! does.
//!
//! JS and wasm live in **separate subtrees and separate manifest lists** on purpose:
//! the JS set is concatenated into the source the extractors parse, so a wasm payload
//! that leaked into that list would put megabytes of binary into it. [`BundleCache::check`]
Expand Down
Loading
Loading