Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion src/Bridge/__tests__/adapt.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it } from 'node:test'
import type { MessageWireInfo, WhatsAppEvent } from '@oxidezap/whatsapp-rust-bridge'
import { adaptBridgeEvent, adaptBridgeMessageWire } from '../adapt.ts'
import { adaptBridgeEvent, adaptBridgeMessageWire, KNOWN_BRIDGE_EVENT_TYPES } from '../adapt.ts'
import { expect } from '../../__tests__/expect.ts'
import {
groupAnnouncementWireFixture,
Expand Down Expand Up @@ -777,5 +777,49 @@ describe('adaptBridgeEvent — anti-corruption layer', () => {
it('truly unknown event types return null (caller drops)', () => {
expect(adaptBridgeEvent({ type: 'invented_2099', data: {} } as never)).toBe(null)
})

/**
* The event type is untrusted: it comes from the runtime, which gets it from
* the server, and the adapter table is a plain object literal.
*
* Every one of these resolves on `Object.prototype`. The lookup used to call
* whatever it found and hand the result on as a canonical event —
* `constructor` produced an object and `toString` a string — while
* `__proto__` and `valueOf` threw instead. Found by the bridge fuzzer.
*/
it('does not resolve an event type through the prototype chain', () => {
for (const inherited of ['constructor', 'toString', 'valueOf', '__proto__', 'hasOwnProperty', 'isPrototypeOf']) {
expect(adaptBridgeEvent({ type: inherited, data: {} } as never)).toBe(null)
}
})
})

/**
* An event with no data slot must be adapted, not thrown on.
*
* The contract is "null on unrecoverable shape mismatch, never a throw", and a
* throw here does not stay local — it propagates into the socket's event
* dispatch and takes down the whole event loop rather than the one event. 30 of
* the 58 declared types threw on this before the slot was normalised.
*/
describe('an absent data slot', () => {
it('never throws, for any declared event type', () => {
for (const type of KNOWN_BRIDGE_EVENT_TYPES) {
for (const data of [undefined, null]) {
expect(() => adaptBridgeEvent({ type, data } as never)).not.toThrow()
}
}
})

it('drops the events that need their data, and keeps the ones that do not', () => {
// `qr` reads a code it has not been given, so there is nothing to emit.
expect(adaptBridgeEvent({ type: 'qr' } as never)).toBe(null)
// `connected` never had a data slot to miss.
expect(adaptBridgeEvent({ type: 'connected' } as never)).toEqual({ type: 'connected' })
// And an adapter with its own fallback still uses it, rather than the
// event being dropped: this is why the slot is normalised rather than
// rejected outright.
expect(adaptBridgeEvent({ type: 'stream_error' } as never)).toEqual({ type: 'streamError', code: 'unknown' })
})
})
})
30 changes: 27 additions & 3 deletions src/Bridge/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,14 +556,38 @@ export const KNOWN_BRIDGE_EVENT_TYPES: ReadonlySet<string> = new Set(Object.keys
*/
export const adaptBridgeEventViaSchema = (event: WhatsAppEvent, logger?: ILogger): CanonicalEvent | null => {
const typed = event as { type: BridgeEventType; data?: unknown }
const adapter = (ADAPTERS as Record<string, AdapterFn<BridgeEventType>>)[typed.type]
if (!adapter) {
// An own property of the table, not anything the prototype chain answers.
//
// The type string comes from the runtime, which gets it from the server, so it
// is untrusted input into a plain-object lookup. `ADAPTERS.constructor` and
// `ADAPTERS.toString` resolve to inherited functions: they were called and
// their return values handed on as canonical events — measured, `constructor`
// produced an object and `toString` a string, neither of which is an event.
// `__proto__` and `valueOf` reached the other failure mode and threw.
if (!Object.hasOwn(ADAPTERS, typed.type)) {
logger?.debug({ eventType: typed.type }, 'unknown bridge event (no canonical mapping)')
return null
}
const adapter = (ADAPTERS as Record<string, AdapterFn<BridgeEventType>>)[typed.type]!
// A missing data slot reads as an empty one.
//
// The contract these adapters are documented under is "null on unrecoverable
// shape mismatch, never a throw", and a throw here does not stay local: it
// propagates into the socket's event dispatch and takes down the whole event
// loop rather than the one event. 30 of the 58 declared types threw on an
// event that arrived with no data — `data.code` on `undefined`.
//
// Normalised rather than rejected, because rejecting would change behaviour
// the adapters already define. Measured on those 30 with an empty slot: 16
// return null of their own accord, 11 return a `noop`, and the remaining three
// return the fallback their own author wrote — `stream_error` its
// `?? 'unknown'`, `pair_error` its 'Unknown pairing error', and
// `offline_sync_completed` its `count: 0`. Nothing is invented here; the throw
// simply becomes whatever the adapter already does with `{}`.
const data = typed.data ?? {}

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 Reject absent history-sync payloads before normalization

When a history_sync event has omitted or null data—the malformed input this change is intended to handle—typed.data ?? {} turns it into a valid plain object. The history-sync adapter consequently bypasses its !isObject(data) rejection, constructs an empty final batch, and src/Socket/events.ts emits a spurious messaging-history.set; before this change the same shape returned null. Preserve the distinction between an absent payload and an actual {} for adapters whose object guard determines whether the event is recoverable.

Useful? React with 👍 / 👎.

// `data` cast here is the only `as` in the public path — the bridge
// runtime is the source of truth that the type matches the discriminator.
return adapter(typed.data as never, logger)
return adapter(data as never, logger)
}

// ─────────────────────────────────────────────────────────────────────────────
Expand Down
45 changes: 0 additions & 45 deletions src/__fuzz__/harness/divergence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -840,17 +840,6 @@ const text = (value: unknown): string => {
}
}

/**
* Every property an empty object inherits.
*
* Enumerated from the prototype itself rather than written out, so the entry that
* relies on it cannot drift from what the runtime actually inherits.
*/
// Object.prototype only: the adapter table is a plain object literal, so that is
// the whole of its prototype chain. Including Function.prototype names would
// excuse a genuine unrecognised event type called `bind` or `name`.
const PROTOTYPE_KEYS: ReadonlySet<string> = new Set(Object.getOwnPropertyNames(Object.prototype))

/**
* An unpaired UTF-16 surrogate, which is what the newsletter encoder differs on.
*
Expand All @@ -860,10 +849,6 @@ const PROTOTYPE_KEYS: ReadonlySet<string> = new Set(Object.getOwnPropertyNames(O
const LONE_SURROGATE =
/[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]|\\ud[89ab][0-9a-f]{2}|\\ud[c-f][0-9a-f]{2}/iu

/** The TypeError shapes a missing or short `data` slot produces. */
const MISSING_DATA_THROWS =
/Cannot read properties of (undefined|null)|is not iterable|Cannot use 'in' operator|is not a function/u

/**
* True when two observation streams hold the same entries in a different order.
*
Expand Down Expand Up @@ -1194,36 +1179,6 @@ export const KNOWN_DIVERGENCES: readonly KnownDivergence[] = [
'For a 64-bit integer field holding a number that is not a 64-bit integer, the bridge encoder throws where upstream converts and encodes. Two rejections, one cause: `1.5`, `NaN` and `Infinity` fail the BigInt conversion (`RangeError: The number … cannot be converted to a BigInt`), and `1e300`, `2**63` and `1.5625e19` fail the range check (`Error: invalid int64: …`). Upstream accepts every one of them — `1.5` as 1, `NaN`/`Infinity`/`1e300` as 0, and `1.5625e19` as a wrapped value that is not the number it was given. Safe integers, numeric strings, `null` and `undefined` are byte-identical on both sides. Measured on `pollUpdateMessage.senderTimestampMs`. baileyrs is plainly the more correct side here — upstream silently sends a wrong value where baileyrs refuses — but it is caller-visible either way: the same content sends on Baileys and throws on baileyrs. Same shape as the float32 entry, and the pair should be decided together.',
review: '2026-11-01'
},
{
id: 'bridge-adapter-prototype-chain-lookup',
target: /^bridge:adapt-(unknown|total)$/u,
status: 'open',
reason:
'The adapter table is a plain object literal indexed by the event type string, so a type of "constructor", "toString" or "valueOf" resolves through Object.prototype: the inherited function is called and its return value is handed on as a canonical event, and "__proto__" resolves to a non-function and throws "adapter is not a function". The type comes from the runtime, which gets it from the server, so an untrusted string is indexing a prototype-bearing lookup table. Both outcomes break the layer\'s stated contract of dropping what it does not recognise. A Map, an Object.create(null) table, or an Object.hasOwn guard fixes it.',
review: '2026-10-01',
when: divergence => PROTOTYPE_KEYS.has(String((divergence.input as { type?: unknown })?.type))
},
{
id: 'bridge-adapter-throws-on-missing-data',
target: /^bridge:adapt-(total|coverage)$/u,
status: 'open',
reason:
'Adapters for declared event types read straight into `data` without checking it is there, so an event that arrives with no data slot — or with a slot missing the field the adapter reads — throws a TypeError instead of returning null. `adapt.ts` documents the opposite ("Result is null on unrecoverable shape mismatch"), and the throw does not stay local: it propagates into the socket event dispatch, which takes out the whole event loop rather than the one event.',
review: '2026-10-01',
// The throw shape *and* a `data` slot that is actually missing. On its own
// the regex matches the most common TypeErrors there are — "is not a
// function", "is not iterable" — so an adapter regression on a well-shaped
// event was classified as the known missing-data problem. The per-type
// coverage counter cannot catch that either: it sees a type that never
// adapts at all, not one that fails on one payload in eight.
when: divergence => {
if (!MISSING_DATA_THROWS.test(text(divergence.local))) return false
const data = (divergence.input as { data?: unknown } | undefined)?.data
// Absent, not a record at all, or a record with nothing in it — the three
// shapes an adapter reading straight into `data` cannot survive.
return plainObject(data)?.length !== undefined ? plainObject(data)!.length === 0 : true
}
},
{
id: 'event-buffer-merge-precedence',
target: 'buffer:differential',
Expand Down
Loading