diff --git a/package-lock.json b/package-lock.json index 053c3180..2cec9280 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@hapi/boom": "^9.1.4", - "@oxidezap/whatsapp-rust-bridge": "0.7.2", + "@oxidezap/whatsapp-rust-bridge": "0.10.0", "long": "^5.3.2", "pino": "^10.3.1", "protobufjs": "^7.6.5" @@ -951,9 +951,9 @@ } }, "node_modules/@oxidezap/whatsapp-rust-bridge": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@oxidezap/whatsapp-rust-bridge/-/whatsapp-rust-bridge-0.7.2.tgz", - "integrity": "sha512-XeBOge5vl2OnafW1Is/TLoReUPzZrC1lkT7mTnp5IKJWR7x3jI36FbtHeRpGE1dm0bVmto94k5EUR5BfRQRUgw==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@oxidezap/whatsapp-rust-bridge/-/whatsapp-rust-bridge-0.10.0.tgz", + "integrity": "sha512-zsAR3hBw8EtnKq1pMfpXH2Q6b21BmTAv+LgQcAPazzga5BlcB0LQiQ7QeMLKNjoIgftlEmPNWMG5ELX12uWTlg==", "license": "MIT" }, "node_modules/@oxlint/binding-android-arm-eabi": { diff --git a/package.json b/package.json index b6267e82..a8e9475c 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "url": "https://github.com/oxidezap/baileyrs/issues" }, "license": "MIT", - "author": "João Lucas ", + "author": "Jo\u00e3o Lucas ", "main": "lib/index.js", "types": "lib/index.d.ts", "exports": { @@ -82,7 +82,7 @@ }, "dependencies": { "@hapi/boom": "^9.1.4", - "@oxidezap/whatsapp-rust-bridge": "0.7.2", + "@oxidezap/whatsapp-rust-bridge": "0.10.0", "long": "^5.3.2", "pino": "^10.3.1", "protobufjs": "^7.6.5" diff --git a/scripts/compatibility/proto-runtime-audit.ts b/scripts/compatibility/proto-runtime-audit.ts index 7d9de83d..a8d8b63c 100644 --- a/scripts/compatibility/proto-runtime-audit.ts +++ b/scripts/compatibility/proto-runtime-audit.ts @@ -51,10 +51,14 @@ const KNOWN_WIRE_GAPS = [ 'BotMetadata.avatarMetadata', 'Message.AudioMessage.mediaKeyDomain', 'Message.DocumentMessage.mediaKeyDomain', + // Renamed rather than absent: WhatsApp schema 2.3000.1044659339 spells field + // 33 `faviconMmsMetadata`, which bridge 0.10.0 regenerated against, while + // baileys 7.0.0-rc13 still declares `faviconMMSMetadata`. The wire is + // identical; the gap closes when upstream regenerates its proto. + 'Message.ExtendedTextMessage.faviconMMSMetadata', 'Message.ImageMessage.mediaKeyDomain', 'Message.MMSThumbnailMetadata.mediaKeyDomain', 'Message.MessageHistoryMetadata.oldestMessageTimestamp', - 'Message.PaymentExtendedMetadata.messageParamsJson', 'Message.StickerMessage.mediaKeyDomain', 'Message.VideoMessage.mediaKeyDomain', 'Message.pollResultSnapshotMessageV3', diff --git a/src/Bridge/primitives.ts b/src/Bridge/primitives.ts index c7c732d2..e72617bf 100644 --- a/src/Bridge/primitives.ts +++ b/src/Bridge/primitives.ts @@ -213,3 +213,7 @@ export const absoluteFromDuration = (seconds: number | undefined): number | unde /** `Date` holds ±8.64e15 ms, which is this many whole seconds. */ const MAX_DATE_SECONDS = 8_640_000_000_000 + +/** Wire collection names and the like: anything that is not a string is not one. */ +export const asStringArray = (x: unknown): string[] => + Array.isArray(x) ? x.filter((item): item is string => typeof item === 'string') : [] diff --git a/src/Bridge/schema.ts b/src/Bridge/schema.ts index 348ab2b5..56df7d10 100644 --- a/src/Bridge/schema.ts +++ b/src/Bridge/schema.ts @@ -39,7 +39,9 @@ import type { } from './types.ts' import { absoluteFromDuration, + asBool, asBoolOr, + asStringArray, asDurationSeconds, asInt64, asJidAddressString, @@ -75,8 +77,11 @@ type AdapterMap = { [K in BridgeEventType]: AdapterFn } * the unexported `PinAction` / `MuteAction` (resolves to `any`). Narrow * once at the call site. */ -const extractAction = (data: { action?: unknown }): Record | undefined => - isObject(data.action) ? data.action : undefined +// Tolerates a missing `data` slot: the adapter table has to be total against +// whatever the runtime sends, and an entry that reads the action before any +// other guard would otherwise throw rather than drop (`bridge:adapt-total`). +const extractAction = (data: { action?: unknown } | undefined): Record | undefined => + isObject(data?.action) ? data.action : undefined /** A group JID is authoritative when an older producer leaves `is_group` false. */ const resolveIsGroup = (wireValue: unknown, chatJid: string): boolean => @@ -352,6 +357,101 @@ const ADAPTERS = { return { type: 'labelAssociation', labelId, chatJid, labeled: asBoolOr(extractAction(data)?.labeled, true) } }, + /** + * The per-message half of `labels.association`, which used to have no path. + * Same canonical event as the chat one, told apart by carrying a message. + */ + message_label_association_update: data => { + const labelId = asString(data?.label_id) + const chatJid = asJidString(data?.chat_jid) + const messageId = asString(data?.message_id) + if (!labelId || !chatJid || !messageId) { + return { type: 'noop', bridgeType: 'message_label_association_update' } + } + return { + type: 'labelAssociation', + labelId, + chatJid, + messageId, + labeled: asBoolOr(extractAction(data)?.labeled, true) + } + }, + + /** + * What a degraded app-state sync left behind. The engine announces the + * connection anyway, so without this a consumer is told a session with no + * push name is healthy and has nothing to read that says otherwise. + */ + app_state_sync_failed: data => ({ + type: 'appStateSyncFailed', + fatal: asStringArray(data?.fatal), + retryable: asStringArray(data?.retryable), + skipped: asStringArray(data?.skipped), + connected: asBoolOr(data?.connected, false) + }), + + /** + * The QR refs ran out. Upstream ends the socket with `timedOut` when its own + * QR timer gives up (`Socket/socket.ts`), which is the same end state, so + * this becomes the same terminal close rather than a new signal to learn. + */ + pairing_qr_codes_exhausted: () => ({ type: 'qrCodesExhausted' }), + + /** + * Another linked device turned link previews on or off account-wide. + * + * Upstream carries this on `settings.update`, reached through app state + * (`Utils/chat-utils.ts` branches on `privacySettingDisableLinkPreviewsAction` + * and emits the action as the value). Same event, different pipe — so the + * value is the action itself, not the decoded flag. + * + * `previews_disabled` is that flag already decoded by the bridge, which is + * what fills the action in when the payload carried the flag alone. The + * bridge only emits this event when the wire carried the flag, so the last + * fallback is unreachable in practice and exists so the value always has + * the field upstream's consumers read. + */ + disable_link_previews_update: data => { + const action = extractAction(data) + return { + type: 'settingUpdate', + setting: 'disableLinkPreviews', + value: { + ...action, + isPreviewsDisabled: asBool(action?.isPreviewsDisabled) ?? asBoolOr(data?.previews_disabled, false) + } + } + }, + + /** + * A pair-code request failed, so any code the user was shown is spent. + * + * Same lifecycle as `pair_error`, which is why it adapts to the same + * canonical event: the socket lives on and the engine takes another + * request, so this must not read as a close, and the code on screen has to + * stop being offered. `pairError` is the handler that does both — a + * pairing code surfaces as `qr` (see `pairing_code` above), and `connecting` + * clears it while saying a fresh one can be asked for. + * + * `rejection` and `backoff` ride along for the log. Neither changes what a + * consumer does here, and the engine owns the retry — but a throttle the + * server named itself is the difference between a code that will come back + * and one that will not, and dropping it leaves that unexplained. + */ + pairing_code_error: data => ({ + type: 'pairError', + error: asString(data?.error) ?? 'pairing code rejected', + rejection: asNumber(data?.rejection), + backoff: asNumber(data?.backoff) + }), + + // Acknowledged with no Baileys equivalent: upstream has no channel for a + // contact deletion (`contacts.update` only upserts), for quick replies, or + // for a call placed on the phone. + contact_removed: () => ({ type: 'noop', bridgeType: 'contact_removed' }), + quick_reply_update: () => ({ type: 'noop', bridgeType: 'quick_reply_update' }), + call_log_sync: () => ({ type: 'noop', bridgeType: 'call_log_sync' }), + // ── Calls ── incoming_call: (data, logger) => adaptIncomingCall(data, logger), missed_call: data => { diff --git a/src/Bridge/types.ts b/src/Bridge/types.ts index c842968f..4f6c82a4 100644 --- a/src/Bridge/types.ts +++ b/src/Bridge/types.ts @@ -45,6 +45,17 @@ export interface CanonicalPairSuccess { export interface CanonicalPairError { type: 'pairError' error: string + /** + * The server's own refusal code, when it answered with one. + * + * Absent when the failure was local (validation, no connection) or the + * request went unanswered — nothing was refused, so there is no status. + * Carried for the log only: the consumer-visible outcome is the same + * either way, and the engine owns the retry. + */ + rejection?: number + /** Seconds the server asked the client to wait before asking again. */ + backoff?: number /** Account JID after pairing (may be set even on error). */ id?: string /** LID for the account. */ @@ -432,10 +443,61 @@ export interface CanonicalLabelAssociation { type: 'labelAssociation' labelId: string chatJid: string - /** `true` = label added to the chat, `false` = removed. */ + /** + * The message the label is on, for a per-message association. + * + * Absent means the label is on the chat itself. The two arrive on separate + * bridge events and upstream carries both on `labels.association`, keyed by + * the association's own `type`. + */ + messageId?: string + /** `true` = label added, `false` = removed. */ labeled: boolean } +/** + * The server ran out of QR refs before anyone scanned one. + * + * Terminal: nothing else is coming on this socket, and the consumer builds a + * new one to be shown a fresh code. + */ +export interface CanonicalQrCodesExhausted { + type: 'qrCodesExhausted' +} + +/** + * A batched app-state sync that did not leave every collection synced. + * + * Collections are named as they appear on the wire. `fatal` is the one a + * consumer usually has to act on: the server refused it and asking again gets + * the same answer. `connected` says whether the session was announced anyway, + * which is the difference between "degraded but usable" and "still retrying" — + * the engine connects on a degraded sync rather than withholding the session, + * so this event is the only thing that says what is missing from it. + */ +export interface CanonicalAppStateSyncFailed { + type: 'appStateSyncFailed' + fatal: string[] + retryable: string[] + skipped: string[] + connected: boolean +} + +/** + * An account-wide setting another linked device changed. + * + * Upstream reaches the same place through app state — `Utils/chat-utils.ts` + * emits `settings.update` for a `privacySettingDisableLinkPreviewsAction` — + * so this is that channel's payload arriving over a different pipe, not a + * new contract. The shape is the event's rather than one setting's: another + * setting the bridge starts reporting is a new arm here, not a new event. + */ +export interface CanonicalSettingUpdate { + type: 'settingUpdate' + setting: 'disableLinkPreviews' + value: proto.SyncActionValue.IPrivacySettingDisableLinkPreviewsAction +} + // ── Calls ── export type CanonicalCallActionType = @@ -693,6 +755,9 @@ export type CanonicalEvent = | CanonicalMarkChatAsReadUpdate | CanonicalLabelEdit | CanonicalLabelAssociation + | CanonicalAppStateSyncFailed + | CanonicalQrCodesExhausted + | CanonicalSettingUpdate | CanonicalIncomingCall | CanonicalUndecryptableMessage | CanonicalLidMappingUpdate diff --git a/src/Compatibility/encode-proto.ts b/src/Compatibility/encode-proto.ts new file mode 100644 index 00000000..cbda13c1 --- /dev/null +++ b/src/Compatibility/encode-proto.ts @@ -0,0 +1,30 @@ +import { encodeProto } from '@oxidezap/whatsapp-rust-bridge' +import { repairProtoMessage } from './proto-runtime.ts' + +/** + * `encodeProto`, with the two inputs the bridge codec stopped accepting put back. + * + * From 0.8.0 the codec refuses an empty string where the schema declares a + * 64-bit integer, and an unpaired surrogate in a text field. Both were written + * before — as `0` and as U+FFFD — and upstream Baileys still encodes both, so a + * message that used to reach the server would now throw in the caller's face. + * This is where that is absorbed, so the strict contract stays true of the + * bridge and the tolerant one stays true of this library. + * + * Repair on failure rather than check on write: the ordinary encode is exactly + * the call it was before, with no scan of any field, and the repair runs only + * for a message that was already going to throw. `encodeProto` returns finished + * bytes rather than a lazy writer, so one try/catch covers it. + */ +export const encodeProtoCompat = (path: string, message: unknown): Uint8Array => { + try { + return encodeProto(path, message) + } catch (error) { + const repaired = repairProtoMessage(path, message) + // Reference equality: nothing was coerced, so the failure is something this + // does not explain — an unmodelled type, a number no int64 can hold — and + // it has to keep propagating rather than be retried into a second throw. + if (repaired === message) throw error + return encodeProto(path, repaired) + } +} diff --git a/src/Compatibility/proto-runtime.ts b/src/Compatibility/proto-runtime.ts index b6ab15ab..bc1dbe2a 100644 --- a/src/Compatibility/proto-runtime.ts +++ b/src/Compatibility/proto-runtime.ts @@ -1,7 +1,7 @@ import { Buffer } from 'node:buffer' import { createRequire } from 'node:module' import type Long from 'long' -import { BinaryReader } from '@oxidezap/whatsapp-rust-bridge' +import { BinaryReader, type Int64 } from '@oxidezap/whatsapp-rust-bridge' import { PROTO_ENUM_SCHEMAS, PROTO_FIELD_FLAG, @@ -115,39 +115,77 @@ const appendBytes = (writer: unknown, bytes: Uint8Array): ProtoWriter => { return appendable as ProtoWriter } +/** + * A UTF-16 code unit with no partner. There is no UTF-8 form for one, so the + * codec refuses it rather than letting `TextEncoder` substitute silently. + */ +const UNPAIRED_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + if (typeof item !== 'string') return item + if (kind === PROTO_FIELD_KIND.signed64 || kind === PROTO_FIELD_KIND.unsigned64) { + return item === '' ? 0 : item + } + if (kind !== PROTO_FIELD_KIND.string) return item + const replaced = item.replace(UNPAIRED_SURROGATE, '\uFFFD') + return replaced === item ? item : replaced +} + const longFromWords = (low: number, high: number, unsigned: boolean): Long => LongRuntime.fromBits(low, high, unsigned) /** - * The neutral codec normally returns safe JS numbers. The compatibility - * facade supplies this reader so the same generated decoder materializes - * protobuf 64-bit words directly as Long values, without a second decode or - * an intermediate string/BigInt allocation. + * The neutral codec returns a JS number while a 64-bit value is exact as a + * double and a plain `{ low, high, unsigned }` past that. The compatibility + * facade supplies this reader so the same generated decoder materializes every + * 64-bit word as a long.js Long instead — uniformly, whatever the magnitude — + * without a second decode or an intermediate string/BigInt allocation. + * + * Uniformity is the point: upstream's types declare `Long` for these fields, + * so a consumer calling `.toNumber()` must not have that work only for values + * under 2^53. The neutral shape is structurally a Long minus its methods, and + * the methods are exactly what upstream code calls. */ class LongBinaryReader extends BinaryReader { - override uint64Number(): number { + override uint64Value(): Int64 { const [low, high] = this.varint64() - return longFromWords(low, high, true) as unknown as number + return longFromWords(low, high, true) } - override int64Number(): number { + override int64Value(): Int64 { const [low, high] = this.varint64() - return longFromWords(low, high, false) as unknown as number + return longFromWords(low, high, false) } - override sint64Number(): number { + override sint64Value(): Int64 { let [low, high] = this.varint64() const sign = -(low & 1) low = ((low >>> 1) | ((high & 1) << (WORD_BITS - 1))) ^ sign high = (high >>> 1) ^ sign - return longFromWords(low, high, false) as unknown as number + return longFromWords(low, high, false) } - override fixed64Number(): number { - return longFromWords(this.sfixed32(), this.sfixed32(), true) as unknown as number + override fixed64Value(): Int64 { + return longFromWords(this.sfixed32(), this.sfixed32(), true) } - override sfixed64Number(): number { - return longFromWords(this.sfixed32(), this.sfixed32(), false) as unknown as number + override sfixed64Value(): Int64 { + return longFromWords(this.sfixed32(), this.sfixed32(), false) } } @@ -281,6 +319,93 @@ const defineLazyValue = (target: DynamicObject, key: string, build: () => unknow }) } +/** + * Type path to schema index, built on first use. + * + * Only the repair path needs it, and that path is only reached after an encode + * has already failed — so an importer that never sends a refused value never + * pays for the 498 entries. + */ +let schemaIdsByPath: Map | undefined +const schemaIdFor = (path: string): number | undefined => { + schemaIdsByPath ??= new Map(PROTO_MESSAGE_SCHEMAS.map(([name], index) => [name, index])) + return schemaIdsByPath.get(path) +} + +/** + * Coerces the two inputs the bridge codec stopped accepting back to what it + * used to write, and returns `value` itself when there was nothing to coerce. + * + * Reference equality is the signal: the caller only reaches here after an + * encode threw, and an unchanged result means the failure was something else + * — a genuinely invalid number, a missing codec — which must keep propagating. + * + * Copy-on-write throughout, like `projectForEncode`: a branch with nothing to + * fix is shared, not rebuilt. + */ +const repairMessage = (schemaId: number, value: unknown, ancestors?: Set): unknown => { + if (!isObject(value)) return value + const fields = PROTO_MESSAGE_SCHEMAS[schemaId]?.[1] + if (!fields) return value + // A message that contains itself, not one that is merely deep. A fixed depth + // cap was the earlier guard and it silently stopped repairing below it: 12 + // nested `ephemeralMessage.message` wrappers is 24 levels, and an empty-string + // int64 under that many threw instead of being coerced. Recursive protobuf + // messages have no depth limit, so only an actual cycle can be refused. + const seen = ancestors ?? new Set() + if (seen.has(value)) return value + seen.add(value) + let output: DynamicObject | undefined + for (const field of fields) { + if (!hasOwn(value, field[0])) continue + const current = value[field[0]] + if (current === null || current === undefined) continue + const repair = (item: unknown): unknown => + field[1] === PROTO_FIELD_KIND.message ? repairMessage(field[2], item, seen) : repairScalar(field[1], item) + let converted: unknown = current + if (field[3] & PROTO_FIELD_FLAG.repeated) { + if (Array.isArray(current)) { + let items: unknown[] | undefined + for (let index = 0; index < current.length; index++) { + const item = repair(current[index]) + if (item !== current[index]) (items ??= current.slice())[index] = item + } + converted = items ?? current + } + } else if (field[3] & PROTO_FIELD_FLAG.map) { + if (isObject(current)) { + let entries: DynamicObject | undefined + for (const key in current) { + const item = repair(current[key]) + if (item !== current[key]) (entries ??= { ...current })[key] = item + } + converted = entries ?? current + } + } else { + converted = repair(current) + } + if (converted !== current) (output ??= { ...value })[field[0]] = converted + } + // The ancestor path, not everything ever visited: the same object reached + // twice in different branches is legitimate and must still be repaired. + seen.delete(value) + return output ?? value +} + +/** + * Repairs a message for a codec addressed by type name rather than schema index. + * + * The send path calls the neutral `encodeProto` directly instead of going + * through this facade's constructors, so it cannot reach the repair the way + * `proto.Message.encode` does. Same coercion, same copy-on-write contract: + * reference equality still means "nothing to fix", so a caller can tell a + * repair from a failure it does not understand. + */ +export const repairProtoMessage = (path: string, message: unknown): unknown => { + const schemaId = schemaIdFor(path) + return schemaId === undefined ? message : repairMessage(schemaId, message) +} + class ProtoCompatibilityRuntime { /** Sparse: filled by `constructorFor`, never by the constructor. */ readonly constructors: Array @@ -425,8 +550,39 @@ class ProtoCompatibilityRuntime { } constructor.encode = (message, writer) => { if (!sourceCodec) throw new Error(`protobuf codec unavailable for ${path}`) - const encoded = sourceCodec.encode(this.projectForEncode(schemaId, message)) - return writer === undefined ? encoded : appendBytes(writer, encoded.finish()) + const projected = this.projectForEncode(schemaId, message) + const encoded = sourceCodec.encode(projected) + // The bridge refuses two inputs it used to accept silently, and upstream + // Baileys still encodes both. Repairing on failure rather than checking + // every field on the way in is what keeps the ordinary encode free: a + // message the codec accepts never reaches the repair, and one that does + // not was already going to throw. + // + // The retry hangs off `finish` because the bridge's writer is lazy — + // `encode` queues the fields and `finish` is what writes them, so a + // refused value surfaces there. Re-encoding from the repaired message is + // safe for the same reason it would not be inside an overridden + // `string()`: that would have to resume after a tag and a length were + // already emitted, where this starts from a fresh writer. + const write = encoded.finish.bind(encoded) + const finish = (): Uint8Array => { + try { + return write() + } catch (error) { + const repaired = repairMessage(schemaId, projected) + if (repaired === projected) throw error + return sourceCodec.encode(repaired).finish() + } + } + if (writer !== undefined) return appendBytes(writer, finish()) + // The codec's own writer is what comes back, with `finish` shadowed on + // the instance rather than replaced by a bare `{ finish }`. The published + // declaration types this return as a protobufjs `Writer`, and a caller + // that chains anything on it — `fork`, `join`, another field — has to + // find the rest of the surface still there. The writer is freshly made + // by this call, so shadowing one method on it touches nothing else. + encoded.finish = finish + return encoded } constructor.decode = (input, length) => { if (!sourceCodec) throw new Error(`protobuf codec unavailable for ${path}`) diff --git a/src/Socket/events.ts b/src/Socket/events.ts index d7159dd2..645eabac 100644 --- a/src/Socket/events.ts +++ b/src/Socket/events.ts @@ -385,7 +385,10 @@ const DISPATCHERS: DispatcherMap = { // belongs on the bus: the QR the user was shown is spent, and `connecting` // clears it while telling the consumer a fresh one is coming. pairError: (evt, { ctx }) => { - ctx.logger.error({ err: evt.error }, 'pairing failed; the engine will retry') + ctx.logger.error( + { err: evt.error, rejection: evt.rejection, backoff: evt.backoff }, + 'pairing failed; the engine will retry' + ) emitRetrying(ctx) }, loggedOut: (evt, dispatchCtx) => @@ -732,13 +735,46 @@ const DISPATCHERS: DispatcherMap = { predefinedId: evt.predefinedId }), labelAssociation: (evt, { ctx }) => - // Inbound sync only ever carries chat associations (the bridge event - // has a `chat_jid`); message-label associations are a separate path. ctx.ev.emit('labels.association', { - association: { type: LabelAssociationType.Chat, chatId: evt.chatJid, labelId: evt.labelId }, + // The two halves arrive on separate bridge events and upstream tells + // them apart by the association's own type, so a message id is what + // decides which of the two shapes this is. + association: evt.messageId + ? { + type: LabelAssociationType.Message, + chatId: evt.chatJid, + messageId: evt.messageId, + labelId: evt.labelId + } + : { type: LabelAssociationType.Chat, chatId: evt.chatJid, labelId: evt.labelId }, type: evt.labeled ? 'add' : 'remove' }), + appStateSyncFailed: (evt, { ctx }) => { + // Logged as well as published: a fatal collection is an operator's + // problem before it is a handler's, and `critical_block` carries the + // push name, so presence stays unavailable until it syncs. + if (evt.fatal.length) { + ctx.logger.warn({ fatal: evt.fatal, connected: evt.connected }, 'app state collections refused by the server') + } + ctx.ev.emit('app-state-sync.failed', { + fatal: evt.fatal, + retryable: evt.retryable, + skipped: evt.skipped, + connected: evt.connected + }) + }, + + // Upstream ends the socket with `timedOut` when its own QR timer gives up + // (`Socket/socket.ts`), so the canonical reconnect handler already knows + // this state. Reporting anything else would make it learn a second one. + qrCodesExhausted: (_, dispatchCtx) => emitClose(dispatchCtx, 'QR refs attempts ended', DisconnectReason.timedOut), + + // Straight onto upstream's own channel for this: `settings.update` already + // declares the `disableLinkPreviews` arm, and a consumer that reads it is + // reading the account-wide setting whichever device changed it. + settingUpdate: (evt, { ctx }) => ctx.ev.emit('settings.update', { setting: evt.setting, value: evt.value }), + // ── Calls ── incomingCall: (evt, { ctx, callbacks }) => { callbacks?.onIncomingCall?.(evt) diff --git a/src/Socket/groups.ts b/src/Socket/groups.ts index 0362367d..ab233714 100644 --- a/src/Socket/groups.ts +++ b/src/Socket/groups.ts @@ -13,7 +13,7 @@ import { type WAMessageKey } from '../Types/index.ts' import { assertArgumentDomain } from '../Utils/argument-domain.ts' -import { generateMessageIDV2, unixTimestampSeconds } from '../Utils/generics.ts' +import { generateMessageIDV2, toNumber, unixTimestampSeconds } from '../Utils/generics.ts' import { proto } from '../WAProto/runtime.ts' import { bridgeGroupMetadataToBaileys } from '../Compatibility/group-metadata.ts' import type { SocketContext } from './types.ts' @@ -42,8 +42,11 @@ export const JOIN_APPROVAL_MODES = ['on', 'off'] as const export type JoinApprovalMode = (typeof JOIN_APPROVAL_MODES)[number] -const inviteExpirationNumber = (value: number | { toNumber(): number } | null | undefined): number => - typeof value === 'number' ? value : (value?.toNumber() ?? 0) +// `toNumber` rather than calling `.toNumber()`: a 64-bit field now crosses the +// bridge as a plain `{ low, high, unsigned }` once the value is too wide to be +// exact as a double, and that shape carries no methods. The helper reads both +// forms, and reconstructs the high word instead of dropping it. +const inviteExpirationNumber = (value: proto.Message.IGroupInviteMessage['inviteExpiration']): number => toNumber(value) export const makeGroupMethods = (ctx: SocketContext) => { const groupMetadata = async (jid: string): Promise => { diff --git a/src/Socket/index.ts b/src/Socket/index.ts index e5f28132..55b0982e 100644 --- a/src/Socket/index.ts +++ b/src/Socket/index.ts @@ -3,10 +3,10 @@ import { randomBytes } from 'node:crypto' import { createWhatsAppClient, type DevicePlatformType, - encodeProto, initWasmEngine, type UploadMediaResult } from '@oxidezap/whatsapp-rust-bridge' +import { encodeProtoCompat } from '../Compatibility/encode-proto.ts' import { normalizeSocketAuthenticationState } from '../Compatibility/internal/auth-state.ts' import { makeMutex } from '../Compatibility/internal/make-mutex.ts' import { isNativeMemoryStore } from '../Compatibility/internal/native-memory-store.ts' @@ -884,7 +884,7 @@ const makeWASocket = (config: UserFacingSocketConfig) => { { statusCode: 501 } ) } - const bytes = encodeProto('Message', message as Record) + const bytes = encodeProtoCompat('Message', message as Record) return (await ctx.getClient()).createParticipantNodesBytes(jids, bytes, extraAttrs ?? {}) }, signalRepository, @@ -972,7 +972,7 @@ const makeWASocket = (config: UserFacingSocketConfig) => { ) }, sendStatusMessage: async (message: Record, recipients: string[]): Promise => { - const bytes = encodeProto('Message', message) + const bytes = encodeProtoCompat('Message', message) return (await ctx.getClient()).sendStatusMessageBytes(bytes, recipients) }, ...makeMessageMethods(ctx), diff --git a/src/Socket/messages.ts b/src/Socket/messages.ts index d7c2c7c2..b5d728e3 100644 --- a/src/Socket/messages.ts +++ b/src/Socket/messages.ts @@ -1,4 +1,4 @@ -import { encodeProto } from '@oxidezap/whatsapp-rust-bridge' +import { encodeProtoCompat } from '../Compatibility/encode-proto.ts' import { planMessageRelay } from '../Compatibility/message-relay.ts' import { receiptMessageKeys } from '../Compatibility/message-keys.ts' import type { @@ -98,7 +98,7 @@ export const makeMessageMethods = (ctx: SocketContext) => ({ } let msgId: string - const msgBytes = encodeProto('Message', msg as Record) + const msgBytes = encodeProtoCompat('Message', msg as Record) if (jid === 'status@broadcast' && options?.statusJidList?.length) { msgId = await client.sendStatusMessageBytes(msgBytes, options.statusJidList) } else { @@ -188,7 +188,7 @@ export const makeMessageMethods = (ctx: SocketContext) => ({ // The message goes to the bridge as the caller built it: the core settles // messageSecret / reportingTokenVersion itself, reusing a caller-set secret // rather than replacing it, so nothing here has to be dropped. - const bytes = encodeProto('Message', message) + const bytes = encodeProtoCompat('Message', message) if (plan.kind === 'retransmission') { await client.retransmitMessageBytes(jid, bytes, plan.input) return plan.messageId @@ -304,7 +304,7 @@ export const makeMessageMethods = (ctx: SocketContext) => ({ } } - const bytes = encodeProto('Message', message) + const bytes = encodeProtoCompat('Message', message) return (await ctx.getClient()).relayMessageBytes(messageKey.remoteJid!, bytes, null) } }) diff --git a/src/Types/Auth.ts b/src/Types/Auth.ts index dbfc3047..c37c4c7b 100644 --- a/src/Types/Auth.ts +++ b/src/Types/Auth.ts @@ -1,3 +1,4 @@ +import type Long from 'long' import type { Buffer } from 'node:buffer' import type { JsStoreCallbacks } from '@oxidezap/whatsapp-rust-bridge' import type { proto } from '../WAProto/runtime.ts' @@ -44,7 +45,13 @@ export type AccountSettings = { /** unarchive chats when a new message is received */ unarchiveChats: boolean /** the default mode to start new conversations with */ - defaultDisappearingMode?: Pick + // `ephemeralSettingTimestamp` restated for the same reason `WAMessage` + // restates `messageTimestamp`: the neutral codec types a 64-bit field as a + // method-less `{ low, high, unsigned }`, and the value this library actually + // stores here is a long.js Long. + defaultDisappearingMode?: Pick & { + ephemeralSettingTimestamp?: number | Long | null + } } /** diff --git a/src/Types/Events.ts b/src/Types/Events.ts index 4035f408..2ba3da21 100644 --- a/src/Types/Events.ts +++ b/src/Types/Events.ts @@ -125,6 +125,19 @@ export type BaileysEventMap = { 'labels.edit': Label 'labels.association': { association: LabelAssociation; type: 'add' | 'remove' } + /** + * A batched app-state sync left collections unsynced. Not an upstream event: + * upstream never withholds a session on app state, so it has nothing to + * report here. + * + * The engine does the opposite of withholding — it announces a connection + * whose critical sync came back degraded, precisely so a session that works + * is usable — and this is what says which collections are missing from it. + * `connected` tells "degraded but usable" from a sync that ran before the + * connection was ready; `fatal` is the half a retry cannot fix. + */ + 'app-state-sync.failed': { fatal: string[]; retryable: string[]; skipped: string[]; connected: boolean } + /** Newsletter-related events */ 'newsletter.reaction': { id: string diff --git a/src/Types/Message.ts b/src/Types/Message.ts index 81897f86..638026c1 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -1,3 +1,4 @@ +import type Long from 'long' import type { Readable } from 'stream' import type { URL } from 'url' import type { @@ -14,8 +15,23 @@ import type { CacheStore } from './Socket.ts' // export the WAMessage Prototypes export { proto as WAProto } -export type WAMessage = Omit & { +export type WAMessage = Omit & { key: WAMessageKey + /** + * `number | Long`, as upstream declares it — not the neutral codec's `Int64`. + * + * From bridge 0.8.0 a 64-bit field is typed `number | { low, high, unsigned }`, + * a plain data shape carrying none of Long's methods. That is what the *codec* + * produces; it is not what this library hands out. The compatibility facade + * supplies a reader that materialises every 64-bit word as a long.js Long + * whatever its magnitude, and the published declaration has always said so, so + * a consumer calling `.toNumber()` is right to expect one. + * + * Declared here rather than left to flow through, because the neutral shape + * otherwise reaches every type derived from `WAMessage` and stops them being + * assignable to upstream's. + */ + messageTimestamp?: number | Long | null category?: string retryCount?: number // Kept deliberately broad by upstream Baileys for legacy stub payloads. diff --git a/src/__fuzz__/bridge-events.fuzz.test.ts b/src/__fuzz__/bridge-events.fuzz.test.ts index 41247a18..47348eb3 100644 --- a/src/__fuzz__/bridge-events.fuzz.test.ts +++ b/src/__fuzz__/bridge-events.fuzz.test.ts @@ -62,10 +62,10 @@ const silentLogger = { * The canonical tag a bridge event type is expected to adapt to. * * A convention plus its exceptions, not a transcription of the adapter's own - * dispatch: the convention is snake_case to camelCase, which holds for 47 of the - * 58 declared types, and the eleven below are the real renames and merges — each - * one a decision somebody made rather than a mechanical transformation. Changing - * any of them has to be a deliberate edit here, which is the point. + * dispatch: the convention is snake_case to camelCase, which holds for every one + * of the 66 declared types except the fifteen below — each of those a decision + * somebody made rather than a mechanical transformation. Changing any of them + * has to be a deliberate edit here, which is the point. * * The last four were missed on the first pass because the fixed seed never drew * a payload that cleared their guards: all four returned `noop` on every run and @@ -91,7 +91,17 @@ const TAG_EXCEPTIONS: Readonly> = { clear_chat_update: 'chatClear', delete_message_for_me_update: 'messageDelete', // A changed contact number is how the runtime tells us about a LID mapping. - contact_number_changed: 'lidMappingUpdate' + contact_number_changed: 'lidMappingUpdate', + // The per-message half of the same canonical association as the chat one. + message_label_association_update: 'labelAssociation', + // Named after the state it reports rather than after the pairing signal. + pairing_qr_codes_exhausted: 'qrCodesExhausted', + // A failed pair-code request leaves the socket in the same place a failed + // pairing does, so it adapts to the same canonical event. + pairing_code_error: 'pairError', + // Named after the channel it lands on — one of upstream's settings, not a + // signal of its own. + disable_link_previews_update: 'settingUpdate' } /** @@ -130,7 +140,13 @@ const UNCONDITIONALLY_INERT: ReadonlySet = new Set([ 'business_status_update', 'contact_sync_requested', 'user_about_update', - 'user_status_mute_update' + 'user_status_mute_update', + // Acknowledged with no Baileys equivalent. Upstream has no channel for a + // contact deletion, quick replies, or a call placed on the phone. See + // `Bridge/schema.ts` for the reason on each. + 'contact_removed', + 'quick_reply_update', + 'call_log_sync' ]) /** @@ -157,6 +173,7 @@ const NOT_YET_REACHABLE: ReadonlySet = new Set([ 'disappearing_mode_changed', 'label_association_update', 'label_edit_update', + 'message_label_association_update', 'newsletter_live_update' ]) diff --git a/src/__fuzz__/harness/__tests__/harness.test.ts b/src/__fuzz__/harness/__tests__/harness.test.ts index ea8df7b7..03c028c7 100644 --- a/src/__fuzz__/harness/__tests__/harness.test.ts +++ b/src/__fuzz__/harness/__tests__/harness.test.ts @@ -607,6 +607,106 @@ describe('fuzz harness — known-divergence allowlist', () => { ) }) + // The one entry where the reference implementation is the non-conforming side, + // so its direction is the whole point and is pinned from both ends. + it('excuses a concatenated merge only when the bridge kept what upstream dropped', async () => { + const { KNOWN_DIVERGENCES } = await import('../divergence.ts') + const registry = KNOWN_DIVERGENCES.filter(entry => entry.id === 'proto-concatenated-message-merge') + assert.equal(registry.length, 1, 'the entry under test is still in the registry') + + const excusedWith = (mutator: string, local: unknown, upstream: unknown): boolean => + applyAllowlist( + [{ target: 'proto:mutation-agreement', input: { mutator }, local, upstream }], + new Date('2026-01-01'), + registry + ).unexcused.length === 0 + const excused = (local: unknown, upstream: unknown): boolean => excusedWith('concatenate', local, upstream) + + // The documented shape: the merge kept a field the second copy did not carry. + assert.ok(excused({ muteAction: { muted: true }, statusPrivacy: { mode: '2' } }, { statusPrivacy: { mode: '2' } })) + // Nested, since a merge happens at whatever depth the field sits. + assert.ok(excused({ a: { b: { kept: 1, shared: 2 } } }, { a: { b: { shared: 2 } } }), 'retention nested') + + // The bridge losing a field is the opposite defect and must still be reported. + assert.ok(!excused({ shared: 2 }, { shared: 2, lost: 1 }), 'a field only upstream produced is not this') + // A value both sides hold that differs is a misread, whatever else was kept. + assert.ok(!excused({ kept: 1, shared: 2 }, { shared: 3 }), 'a changed value is never this entry') + // Two identical decodes are not a finding to excuse. + assert.ok(!excused({ shared: 2 }, { shared: 2 }), 'nothing kept is nothing to explain') + // And retention in one branch does not license a loss in another. + assert.ok(!excused({ a: { kept: 1 }, b: {} }, { a: {}, b: { lost: 1 } }), 'a loss beside a retention still fails') + + // The repeated field that diverges sits inside a singular submessage the + // payload carries twice: merging concatenates every copy's elements, while + // upstream replaces the submessage and keeps only the last copy's. So + // upstream's array is our *tail*, and what precedes it is what merging kept. + // Distinct values on purpose — equal ones pass under either reading and hid + // this being backwards. + assert.ok(excused({ ids: ['first', 'last'] }, { ids: ['last'] }), 'the earlier copy retained ahead of upstream') + assert.ok(excused({ ids: ['a', 'b', 'c'] }, { ids: ['b', 'c'] }), 'two copies merged, upstream kept the last') + assert.ok(!excused({ ids: ['first', 'last'] }, { ids: ['first'] }), 'upstream keeps the last copy, not the first') + assert.ok(!excused({ ids: ['a', 'b'] }, { ids: ['c'] }), 'a changed element is not a retention') + assert.ok(!excused({ ids: ['a'] }, { ids: ['a', 'a'] }), 'the bridge holding fewer elements is a loss') + + // Merge semantics is the justification, so the payload has to actually carry + // the message twice. The same retention shape from any other mutator is a + // field the bridge invented, which is a different thing entirely. + const retained = [{ kept: 1, shared: 2 }, { shared: 2 }] as const + assert.ok(!excusedWith('flip-bit', ...retained), 'a retention from another mutator is not merge') + assert.ok(!excusedWith('lying-length', ...retained), 'nor from a lying length') + // A chain counts wherever the concatenation sits: it is what put the message + // in twice, and a byte corrupted afterwards does not undo that. + assert.ok(excusedWith('flip-bit → concatenate', ...retained), 'concatenation last') + assert.ok(excusedWith('concatenate → flip-bit', ...retained), 'concatenation first') + assert.ok(excusedWith('concatenate → concatenate', ...retained), 'three copies') + }) + + // The two decoders resolve an undecodable region in opposite directions, and + // that asymmetry is the only thing separating this entry's salvage from a + // misread — so it is pinned from both ends, like the merge entry above. + it('excuses a lying-length salvage only when it is the whole difference', async () => { + const { KNOWN_DIVERGENCES } = await import('../divergence.ts') + const registry = KNOWN_DIVERGENCES.filter(entry => entry.id === 'proto-lying-length-salvage') + assert.equal(registry.length, 1, 'the entry under test is still in the registry') + + const excusedWith = (input: unknown, local: unknown, upstream: unknown): boolean => + applyAllowlist([{ target: 'proto:mutation-agreement', input, local, upstream }], new Date('2026-01-01'), registry) + .unexcused.length === 0 + const salvage = { mutator: 'lying-length', path: 'Message.ImageMessage' } + const excused = (local: unknown, upstream: unknown): boolean => excusedWith(salvage, local, upstream) + + // The shape measured on the one case in a 10001-run deep sweep: the bridge + // kept two fields upstream stepped past, and the one shared string differs + // only where each side rendered bytes it could not decode. protobufjs + // swallows the ASCII bytes after a lead byte; the bridge substitutes per + // byte and keeps them — so upstream's ASCII is a subsequence of ours. + const url = { local: 'A�B�C', upstream: 'A\u{10FFFF}C' } + assert.ok(excused({ kept: '1', url: url.local }, { url: url.upstream }), 'retention and substitution together') + assert.ok(excused({ kept: '1', url: 'A�C' }, { url: 'A�C' }), 'retention alone') + + // The retention is what satisfies the entry; the substitution rides beside + // it and can never carry it alone, or a Unicode decoding regression on this + // route would excuse itself. + assert.ok(!excused({ url: url.local }, { url: url.upstream }), 'a substitution with nothing retained') + // And U+FFFD is the substitution. Without requiring it, "both sides hold + // some non-ASCII with a matching ASCII skeleton" admits any two decodings. + assert.ok(!excused({ kept: '1', url: 'a雪b' }, { url: 'aéb' }), 'two decodings, neither a substitution') + + // Each half of "the difference is the salvage" fails on its own terms. + assert.ok(!excused({ kept: '1', url: url.local }, { url: url.upstream, lost: 1 }), 'a field only upstream produced') + assert.ok(!excused({ kept: 1, mode: '2' }, { mode: '3' }), 'a changed scalar beside a retention') + assert.ok(!excused({ kept: 1, name: 'goodbye' }, { name: 'hello' }), 'a changed ASCII string') + assert.ok(!excused({ kept: '1', url: 'A�C' }, { url: 'AB\u{10FFFF}C' }), 'ASCII upstream produced that we lack') + assert.ok(!excused({ kept: '1', url: 'AB�' }, { url: '\u{10FFFF}BA' }), 'the same ASCII in a different order') + assert.ok(!excused({ kept: 1, s: 'ab' }, { s: 'ba' }), 'a reordered pure-ASCII string is not a substitution') + assert.ok(!excused({ url: url.local }, { url: url.local }), 'nothing differs at all') + + // The mutator and the path are the premise: this salvage is what a rewritten + // length prefix does, on the one type the sweep produced it for. + assert.ok(!excusedWith({ ...salvage, mutator: 'flip-bit' }, { kept: 1, a: 1 }, { a: 1 }), 'another mutator') + assert.ok(!excusedWith({ ...salvage, path: 'Message.AudioMessage' }, { kept: 1, a: 1 }, { a: 1 }), 'another type') + }) + it('keeps every shipped registry entry well-formed', async () => { const { KNOWN_DIVERGENCES } = await import('../divergence.ts') const ids = new Set() @@ -787,4 +887,30 @@ describe('fuzz harness — protobuf wire canonicaliser', () => { assert.equal(canonicalWire(Uint8Array.from([...tag(536_870_912, 0), 0x00])), undefined) assert.notEqual(canonicalWire(Uint8Array.from([...tag(536_870_911, 0), 0x00])), undefined) }) + + /** + * The scan reads a field number under either encoder's spelling, which is only + * safe while no number is claimed by two different fields. + * + * True across the schema today — 2422 claims, no collisions — and the thing + * that would break it is a schema regeneration, which is exactly the moment + * nobody re-reads the comment asserting it. So it is swept rather than + * assumed. `factsFor` already degrades a contested number to opaque bytes so a + * run cannot misframe silently; this is what says the degradation happened. + */ + it('finds no field number claimed by two different fields, anywhere in the schema', async () => { + const { contestedFieldNumbers } = await import('../schema-context.ts') + const { PROTO_MESSAGE_SCHEMAS } = await import('../../../WAProto/compatibility-schema.ts') + + const collisions = PROTO_MESSAGE_SCHEMAS.map(([path]) => [path, contestedFieldNumbers(path)] as const).filter( + ([, numbers]) => numbers.length > 0 + ) + assert.deepEqual( + collisions, + [], + `a field number is claimed by two fields, so the scan degraded it to opaque bytes: ${collisions + .map(([path, numbers]) => `${path} #${numbers.join(', #')}`) + .join('; ')}` + ) + }) }) diff --git a/src/__fuzz__/harness/divergence.ts b/src/__fuzz__/harness/divergence.ts index f65931fa..6f759c3d 100644 --- a/src/__fuzz__/harness/divergence.ts +++ b/src/__fuzz__/harness/divergence.ts @@ -68,12 +68,14 @@ export interface KnownDivergence { const NOT_ENCODED_FIELDS: readonly string[] = [ 'Message.AudioMessage.mediaKeyDomain', 'Message.DocumentMessage.mediaKeyDomain', + // Not absent but renamed — see RENAMED_PROTO_FIELDS. Handed upstream's + // spelling the bridge writes nothing, which is what this sweep measures. + 'Message.ExtendedTextMessage.faviconMMSMetadata', 'Message.ImageMessage.mediaKeyDomain', 'Message.MMSThumbnailMetadata.mediaKeyDomain', 'Message.StickerMessage.mediaKeyDomain', 'Message.VideoMessage.mediaKeyDomain', 'Message.MessageHistoryMetadata.oldestMessageTimestamp', - 'Message.PaymentExtendedMetadata.messageParamsJson', 'SyncActionValue.businessBroadcastAssociationAction', 'SyncActionValue.AgentAction.deviceID', 'SyncActionValue.ChatAssignmentAction.deviceAgentID' @@ -90,6 +92,10 @@ const NOT_ENCODED_FIELDS: readonly string[] = [ const RENAMED_PROTO_FIELDS: readonly (readonly [upstream: string, bridge: string])[] = [ ['deviceAgentID', 'deviceAgentId'], ['deviceID', 'deviceId'], + // WhatsApp schema 2.3000.1044659339, which bridge 0.10.0 regenerated against. + // Field 33 is unchanged, so only the key differs; it closes when upstream + // regenerates its own proto. + ['faviconMMSMetadata', 'faviconMmsMetadata'], ['oldestMessageTimestamp', 'oldestMessageTimestampInWindow'] ] @@ -133,17 +139,39 @@ export const undoRenames = (value: unknown, depth = 0): unknown => { * `ChatAssignmentAction.deviceAgentID` would pass, because `undoRenames` * restores the first and the set forgives the second. * - * And a leaf name is not unique. `messageParamsJson` is unwritten on - * `Message.PaymentExtendedMetadata`, while the schema declares another on - * `Message.InteractiveMessage.NativeFlowMessage` — so accepting the bare leaf - * at any nesting depth would excuse a new drop of the second as though it were - * the documented first. + * And a leaf name is not unique. `messageParamsJson` was the live case — a gap + * on `Message.PaymentExtendedMetadata` while the schema declares another on + * `Message.InteractiveMessage.NativeFlowMessage`, so a bare leaf accepted at any + * nesting depth would have excused a new drop of the second as though it were + * the documented first. Bridge 0.10.0 writes both, which closes that example + * without closing the hazard: of the leaves left, only `mediaKeyDomain` sits on + * more than one type, and it is a gap on all six. The first holder to be fixed + * on its own puts the case straight back, and keying by path is what means it + * does not have to be noticed for the sweep to catch it. * * Keyed by `.` and measured rather than derived: this * is the absence the decode targets actually produced. A drop anywhere else, * including the same leaf under a different holder, still fails. */ -const DECODE_OMITTED_PATHS: ReadonlySet = new Set(['SyncActionValue.businessBroadcastAssociationAction']) +const DECODE_OMITTED_PATHS: ReadonlySet = new Set([ + 'SyncActionValue.businessBroadcastAssociationAction', + // Measured after the 0.8.0 bump, on a ContextInfo whose quoted message is a + // video: `Message.VideoMessage.mediaKeyDomain` is one of the eleven the bridge + // never writes, and this is the path a generative draw puts it at. Listed + // rather than matched by leaf, so the same field under another holder is still + // a drop nobody has looked at. + 'ContextInfo.quotedMessage.videoMessage.mediaKeyDomain', + // Measured after the 0.10.0 bump, all four the same two gaps reached through + // paths the older schema did not put a generative draw at. `mediaKeyDomain` + // is one of the eleven the bridge never writes; `pollResultSnapshotMessageV3` + // is field 114 upstream and 115 here, so upstream's bytes for it are a field + // this side does not have. Listed by path, like the video one above, so the + // same leaf under a holder nobody has looked at is still a drop. + 'ContextInfo.quotedMessage.audioMessage.mediaKeyDomain', + 'ContextInfo.quotedMessage.pollResultSnapshotMessageV3', + 'Message.ExtendedTextMessage.contextInfo.quotedMessage.pollResultSnapshotMessageV3', + 'Message.ExtendedTextMessage.faviconMMSMetadata.mediaKeyDomain' +]) /** * True when the two decodes agree once the documented absences are allowed on @@ -208,13 +236,16 @@ const inputPath = (input: unknown): string | undefined => { } /** - * The ten explicit-presence fields the bridge encoder drops at their zero value. + * The nine explicit-presence fields the bridge encoder drops at their zero value. * * Enumerated rather than left to the target name. The `proto:presence` sweep * covers all 1696 proto3-optional fields, and the whole value of a sweep is that - * an eleventh has to fail rather than be absorbed into the entry describing the - * ten. (`BotAvatarMetadata`'s five presence fields are not here: the bridge does - * not implement that type at all, so they route to the unknown-type entry.) + * a tenth has to fail rather than be absorbed into the entry describing the + * nine. `PaymentExtendedMetadata.messageParamsJson` was here until bridge 0.10.0 + * started writing it — measured, not assumed: both encoders now emit `1a 00` for + * an explicit empty string. (`BotAvatarMetadata`'s five presence fields are not + * here either: the bridge does not implement that type at all, so they route to + * the unknown-type entry.) */ const PRESENCE_DROPPED_FIELDS: readonly string[] = [ 'Message.AudioMessage.mediaKeyDomain', @@ -224,7 +255,6 @@ const PRESENCE_DROPPED_FIELDS: readonly string[] = [ 'Message.StickerMessage.mediaKeyDomain', 'Message.VideoMessage.mediaKeyDomain', 'Message.MessageHistoryMetadata.oldestMessageTimestamp', - 'Message.PaymentExtendedMetadata.messageParamsJson', 'SyncActionValue.AgentAction.deviceID', 'SyncActionValue.ChatAssignmentAction.deviceAgentID' ] @@ -431,15 +461,25 @@ const carriesOutOfRangeFloat = (value: unknown, depth = 0): boolean => { * Measured rather than sampled: nine seeds and 21,000 generated cases produce * exactly these twelve `path#number` pairs and no others. A thirteenth is new * data loss and must be looked at, which is the entire point of listing them. + * + * Bridge 0.10.0 turned one over without changing the count: `#33` on + * `ExtendedTextMessage` joined (the favicon rename) and + * `Message.PaymentExtendedMetadata#3` left, because that field is now written — + * both encoders emit `1a 00` for an explicit empty string. A member that no + * longer reproduces excuses nothing today and silently excuses a regression the + * day the field breaks again, which is why it is removed rather than left. */ const KNOWN_OMITTED_FIELDS: ReadonlySet = new Set([ 'BotMetadata#1', 'Message.AudioMessage#23', 'Message.DocumentMessage#22', + // The renamed favicon field — see RENAMED_PROTO_FIELDS. Handed upstream's + // spelling the bridge writes nothing for it, so the bytes come out short by + // exactly this field. + 'Message.ExtendedTextMessage#33', 'Message.ImageMessage#33', 'Message.MMSThumbnailMetadata#8', 'Message.MessageHistoryMetadata#2', - 'Message.PaymentExtendedMetadata#3', 'Message.StickerMessage#23', 'Message.VideoMessage#32', 'SyncActionValue#65', @@ -489,41 +529,6 @@ const carriesBeyondSafeInteger = (value: unknown, depth = 0): boolean => { return Object.values(value as Record).some(nested => carriesBeyondSafeInteger(nested, depth + 1)) } -/** - * True when the input really carries an empty string and the bridge still encoded. - * - * The entry documents one coercion, and keying on upstream's error text alone - * excused every local outcome — including the bridge dropping the field or - * writing something else entirely. This ties the excuse to an input that - * actually holds the empty string, and to the bridge having produced bytes - * rather than nothing. - * - * It stops short of proving the coerced field encoded as *zero*: the message - * carries other populated fields whose values are legitimately non-zero, so - * telling the coerced field from its neighbours needs the schema, which this - * registry has no access to. That is the remaining gap, and it is smaller than - * the one it replaces. - */ -const coercedAnEmptyString = (divergence: Divergence): boolean => { - const carriesEmptyString = (value: unknown, depth = 0): boolean => { - if (depth > 12) return false - if (value === '') return true - if (Array.isArray(value)) return value.some(item => carriesEmptyString(item, depth + 1)) - if (typeof value !== 'object' || value === null) return false - return Object.values(value as Record).some(nested => carriesEmptyString(nested, depth + 1)) - } - if (!carriesEmptyString(divergence.input)) return false - // And the bridge really did coerce it to zero. The registry cannot tell the - // coerced field from its neighbours — that needs the schema — so the target - // answers instead: it re-encodes the same message with every empty string - // replaced by `'0'` and tags the finding with whether the bytes match. - // Measured on `Message.AudioMessage.fileLength`, `''` and `'0'` both encode to - // `0a017520002803` where `'5'` gives `0a017520052803`, so a regression that - // wrote a different value or dropped the field is tagged `not coerced` and - // stops being excused here. - return hasTag(divergence, 'empty string coerced to zero') -} - /** Removes one property wherever it appears, so a predicate can ask what is left. */ const withoutKey = (value: unknown, name: string, depth = 0): unknown => { if (depth > 12) return value @@ -898,6 +903,117 @@ const text = (value: unknown): string => { const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(? value.replaceAll(NON_ASCII_GLOBAL, '') + +const isSubsequence = (needle: string, haystack: string): boolean => { + let index = 0 + for (const character of haystack) { + if (character === needle[index]) index++ + if (index === needle.length) return true + } + return index === needle.length +} + +/** + * A string pair that differs only in how each side rendered bytes it could not decode. + * + * `lying-length` makes a prefix cover a region that is not valid UTF-8, and the + * two decoders resolve it differently in a *directional* way, which is what + * makes it checkable. protobufjs decodes greedily: a lead byte swallows the + * bytes after it into one code point, ASCII ones included. The bridge rejects + * the sequence and substitutes U+FFFD per byte, so every ASCII byte it met + * survives. + * + * Two conditions, and both are needed. The bridge's side must actually carry + * U+FFFD — that is the substitution itself, and without it "both sides have + * some non-ASCII" says nothing: `a\u96eab` against `a\u00e9b` would pass while + * being an ordinary decoder disagreement, not a salvage. Measured on the one + * case this entry covers: 25 of them. + * + * And upstream's ASCII must appear in the bridge's, in order — 88 characters + * against 98 in that same case. That direction is what separates a substitution + * from a misread: a value genuinely read differently drops, reorders or + * rewrites a character upstream produced, and fails here. + */ +const substitutedUndecodableBytes = (local: unknown, upstream: unknown): boolean => + typeof local === 'string' && + typeof upstream === 'string' && + local !== upstream && + local.includes('\ufffd') && + NON_ASCII.test(upstream) && + isSubsequence(asciiOf(upstream), asciiOf(local)) + +/** + * True when the bridge kept fields upstream dropped, and nothing else differs. + * + * Directional and total: upstream may not carry a key the bridge lacks, no value + * both sides hold may differ, and at least one field has to have been kept — so + * this can never excuse a misread, only a retention. + * + * `alsoExplains` is the one seam. A caller may name a *specific* leaf difference + * its entry documents, which then stops being fatal — but it contributes + * nothing to the count, so it can never satisfy an entry on its own. The + * retention still has to be there. The default explains nothing, so an entry + * that does not opt in keeps the strict reading. Everything else stays as it + * was: a key only upstream produced still fails whatever the hook says, + * because that is the bridge losing data. + */ +const keptFieldsUpstreamDropped = ( + local: unknown, + upstream: unknown, + alsoExplains: (ours: unknown, theirs: unknown) => boolean = () => false +): number | undefined => { + const walk = (ours: unknown, theirs: unknown, depth: number): number | undefined => { + if (depth > 12) return sameShape(ours, theirs) ? 0 : undefined + if (Array.isArray(ours) || Array.isArray(theirs)) { + // A *suffix*, not a prefix — the direction is the whole point and was + // backwards here. The repeated field that diverges sits inside a singular + // submessage the payload carries twice: merging concatenates every copy's + // elements, while upstream replaces the submessage and keeps only the last + // copy's. So upstream's array is our tail, and the elements before it are + // what merging retained. Measured on the deep sweep: 18 array pairs in that + // relation against 2 the old prefix reading matched. + // + // Upstream being the longer side is still the bridge losing elements, and + // still fails. + if (!Array.isArray(ours) || !Array.isArray(theirs) || ours.length < theirs.length) return undefined + const retained = ours.length - theirs.length + let kept = retained + for (const [index, item] of theirs.entries()) { + const inner = walk(ours[retained + index], item, depth + 1) + if (inner === undefined) return undefined + kept += inner + } + return kept + } + const ourKeys = plainObject(ours) + const theirKeys = plainObject(theirs) + if (ourKeys === undefined || theirKeys === undefined) { + if (sameShape(ours, theirs)) return 0 + // Zero, not one: an explained leaf is permitted, never sufficient. + return alsoExplains(ours, theirs) ? 0 : undefined + } + const ourRecord = ours as Record + const theirRecord = theirs as Record + // A key only upstream produced is the bridge losing data, which is the + // opposite defect and must still be reported. + if (theirKeys.some(key => !Object.hasOwn(ourRecord, key))) return undefined + let kept = ourKeys.length - theirKeys.length + for (const key of theirKeys) { + const inner = walk(ourRecord[key], theirRecord[key], depth + 1) + if (inner === undefined) return undefined + kept += inner + } + return kept + } + return walk(local, upstream, 0) +} + /** * The registry. * @@ -914,7 +1030,44 @@ const LONE_SURROGATE = * generated input, is evidence the sweeps work — it is not new information, and * recording it as new would misrepresent what this suite found. */ +/** Every step of a mutation chain, which `mutate` records as `a → b`. */ +const mutatorChain = (input: unknown): readonly string[] => { + const mutator = (input as { mutator?: unknown } | undefined)?.mutator + return typeof mutator === 'string' ? mutator.split('\u2192').map(step => step.trim()) : [] +} + +/** The mutator that shaped the bytes last, which is what the framing reflects. */ +const lastMutator = (input: unknown): string | undefined => mutatorChain(input).at(-1) + export const KNOWN_DIVERGENCES: readonly KnownDivergence[] = [ + { + id: 'proto-lying-length-salvage', + target: 'proto:mutation-agreement', + status: 'open', + reason: + 'The `lying-length` mutator rewrites a length prefix so a submessage claims a different extent, and the result still frames as protobuf — so it lands here rather than under mutation-interpretation. Both decoders then salvage the overlapping bytes, and they salvage different things: the bridge reads fields out of the region the prefix now covers that protobufjs steps past, and each side replaces the invalid UTF-8 it meets with its own substitution. No wire this library sends produces those bytes, and neither reading is more correct than the other, since the sender never wrote either value. Open rather than intended because nobody has decided whether the bridge should refuse a submessage whose declared length disagrees with its content, which is the only answer that would make the two agree.', + review: '2026-11-12', + when: divergence => + lastMutator(divergence.input) === 'lying-length' && + inputPath(divergence.input) === 'Message.ImageMessage' && + // The difference has to *be* the salvage, not merely accompany it. + // "Both sides decoded something non-empty" was true of any misdecode + // under this mutator, which is what left a real regression excusable. + // + // The two documented halves are exactly the two this admits, and the + // retention is the one that counts: fields the bridge read out of the + // region the rewritten prefix now covers and protobufjs stepped past. + // A string whose undecodable bytes the bridge replaced with U+FFFD is + // permitted beside it but never sufficient alone, or a Unicode decoding + // regression on this route would excuse itself. Everything else — a key + // only upstream produced, a changed number, a string that moved bytes + // both sides could decode — still fails. + (keptFieldsUpstreamDropped( + normalise(divergence.local), + normalise(divergence.upstream), + substitutedUndecodableBytes + ) ?? 0) > 0 + }, { id: 'to-number-high-word', target: 'pure:toNumber', @@ -975,6 +1128,13 @@ export const KNOWN_DIVERGENCES: readonly KnownDivergence[] = [ // string field comes back as U+FFFD there too — measured, `\ud800` becomes // three replacement characters upstream and stays raw in baileyrs, which does // not round-trip. The predicate keeps this to inputs that actually carry one. + // + // From bridge 0.8.0 the substitution is deliberate rather than incidental: + // the codec refuses the surrogate outright, and `encodeProtoCompat` puts + // U+FFFD back so the bytes are the ones this library has always sent. + // Upstream writes the surrogate raw as WTF-8 (`edbfbf` against this side's + // `efbfbd`), which is not valid UTF-8 — the difference the entry describes + // is unchanged, only its cause moved. target: /^pure:(encodeNewsletterMessage|generateForwardMessageContent)$/u, status: 'open', // Scoped to the surrogate, not the whole helper. Without a predicate this @@ -1275,8 +1435,8 @@ export const KNOWN_DIVERGENCES: readonly KnownDivergence[] = [ target: 'proto:presence', status: 'open', // Named, not target-wide. The sweep's whole point is that it covers every - // proto3-optional field; an entry matching the target alone would route an - // eleventh drop into the finding that describes the ten and leave the + // proto3-optional field; an entry matching the target alone would route a + // tenth drop into the finding that describes the nine and leave the // nightly green on a new regression. when: divergence => typeof divergence.input === 'string' && @@ -1284,7 +1444,7 @@ export const KNOWN_DIVERGENCES: readonly KnownDivergence[] = [ field => divergence.input === `${field} = 0` || divergence.input === `${field} = ""` ), reason: - 'An explicit-presence (proto3 optional) field set to its zero value is not encoded by the bridge, where protobufjs writes it. 10 of the 1696 such fields are affected, including mediaKeyDomain on all six media message types (image, video, audio, document, sticker, thumbnail). Explicit presence exists precisely so a zero can be distinguished from unset, so this loses information the schema was written to carry. ALREADY TRACKED: every affected field appears in KNOWN_WIRE_GAPS in scripts/compatibility/proto-runtime-audit.ts. What is new here is only the count and the exhaustive sweep behind it.', + 'An explicit-presence (proto3 optional) field set to its zero value is not encoded by the bridge, where protobufjs writes it. 9 of the 1696 such fields are affected, including mediaKeyDomain on all six media message types (image, video, audio, document, sticker, thumbnail). Explicit presence exists precisely so a zero can be distinguished from unset, so this loses information the schema was written to carry. Bridge 0.10.0 closed a tenth, PaymentExtendedMetadata.messageParamsJson, which is why the count moved. ALREADY TRACKED: every affected field appears in KNOWN_WIRE_GAPS in scripts/compatibility/proto-runtime-audit.ts. What is new here is only the count and the exhaustive sweep behind it.', review: '2026-10-01' }, { @@ -1306,7 +1466,7 @@ export const KNOWN_DIVERGENCES: readonly KnownDivergence[] = [ (divergence.local === '' || divergence.local === '') && NOT_ENCODED_FIELDS.some(field => text(divergence.input).includes(field)), reason: - 'Upstream encodes these fields and the bridge writes nothing at all for them. Eleven of 2421 non-map fields: mediaKeyDomain on all six media types, MessageHistoryMetadata.oldestMessageTimestamp, PaymentExtendedMetadata.messageParamsJson, SyncActionValue.businessBroadcastAssociationAction, AgentAction.deviceID and ChatAssignmentAction.deviceAgentID. ALREADY TRACKED: every one is in KNOWN_WIRE_GAPS in scripts/compatibility/proto-runtime-audit.ts — the six presence drops and the two renames also have their own entries here, seen from a different angle. The sweep previously skipped the case where only the bridge produced no bytes, so it reported exhaustive coverage of fields it had not checked; this entry is what that skip was hiding.', + 'Upstream encodes these fields and the bridge writes nothing at all for them. Eleven of 2421 non-map fields: mediaKeyDomain on all six media types, MessageHistoryMetadata.oldestMessageTimestamp, ExtendedTextMessage.faviconMMSMetadata (renamed rather than absent — see RENAMED_PROTO_FIELDS; handed upstream spelling the bridge writes nothing, which is what this sweep measures), SyncActionValue.businessBroadcastAssociationAction, AgentAction.deviceID and ChatAssignmentAction.deviceAgentID. PaymentExtendedMetadata.messageParamsJson was a twelfth until bridge 0.10.0 started writing it. ALREADY TRACKED: every one is in KNOWN_WIRE_GAPS in scripts/compatibility/proto-runtime-audit.ts — the six presence drops and the two renames also have their own entries here, seen from a different angle. The sweep previously skipped the case where only the bridge produced no bytes, so it reported exhaustive coverage of fields it had not checked; this entry is what that skip was hiding.', review: '2026-10-01' }, { @@ -1361,6 +1521,34 @@ export const KNOWN_DIVERGENCES: readonly KnownDivergence[] = [ // classified as this known difference, on every proto target. when: divergence => text(divergence.local).includes('invalid float32') && carriesOutOfRangeFloat(divergence.input) }, + { + id: 'proto-concatenated-message-merge', + target: 'proto:mutation-agreement', + status: 'intended', + reason: + 'Protobuf defines concatenation as merging: "for embedded message fields, the parser merges multiple instances of the same field, as if with the Message::MergeFrom method". From bridge 0.8.1 the codec does that — a singular message field read twice merges instead of the second read replacing the first — and protobufjs assigns, dropping everything the earlier instance carried. So on a payload carrying the same message field twice, the bridge returns the merged object and upstream returns only the last one. The same rule applies to repeated fields, which merging concatenates rather than replaces: on a payload carrying three copies of a Message.PollCreationMessage, contextInfo.mentionedJid holds both elements here and only the last one upstream. Measured across six mutated payloads after the 0.8.1 bump: SyncActionValue, MessageContextInfo, Message.ReactionMessage, Message.ExtendedTextMessage and Message.PollCreationMessage, retaining between one and nine fields. This is the one entry in the registry where the *reference* implementation is the non-conforming side, so it is intended rather than open: aligning would mean deliberately dropping fields the wire format says are there.', + review: '2027-02-01', + // The mutator as well as the shape. Merge semantics is the justification, and + // it only applies to a payload that actually carries the message twice — + // without this, any other mutator that made the bridge invent a field or an + // array element produced the same retention shape and was filed as the + // intended concatenation difference. Both reviewers caught it independently. + // + // *Contains* concatenate, not ends with it. Both reviewers proposed the last + // step, and that is too strict: concatenation is what put the message in + // twice, and a bit flipped afterwards does not undo it. Measured — scoping + // to the final step left `concatenate → flip-bit` and + // `concatenate → replace-byte` unexcused, both pure retentions. + // + // Retention only, and it has to be the whole difference. Upstream carrying a + // key the bridge lacks is the bridge losing data — the opposite defect — + // and any value that differs where both sides hold the key is a misread. + // Both still fail. Without the `> 0` this would also excuse two identical + // decodes, which is not a finding at all. + when: divergence => + mutatorChain(divergence.input).includes('concatenate') && + (keptFieldsUpstreamDropped(divergence.local, divergence.upstream) ?? 0) > 0 + }, { id: 'proto-decode-invalid-utf8', target: 'proto:mutation-agreement', @@ -1452,10 +1640,12 @@ export const KNOWN_DIVERGENCES: readonly KnownDivergence[] = [ "protobufjs ignores the wire type of a field it recognises; the bridge honours it. Minimal case, verified directly: `0a 02 08 20` against SyncActionValue is field 1 (`optional int64 timestamp`) written as wire type 2, wrapping the legal `08 20`. protobufjs runs its generated `case 1: reader.int64()` regardless of the wire type, reads the length byte as the value, then meets the inner `08 20` at the next tag and overwrites it — so the wrapper is flattened away and it reports `timestamp: 32` at any nesting depth. The bridge sees a varint field arriving as length-delimited, treats it as unknown, and reports `{}`. The spec is on the bridge's side: a wire type that does not match the declared one makes the field unknown, and silently reinterpreting it is how a parser reads a value the sender never wrote. The nesting-bomb mutator reaches this on every path whose field 1 is not a message, which is most of them.", review: '2027-02-01', when: divergence => - // The exact mutator, not a substring of the chain. `mutate` records - // `nesting-bomb → flip-bit`, so a substring test excused whatever the - // *second* mutator produced merely because a nesting bomb ran first. - (divergence.input as { mutator?: unknown } | undefined)?.mutator === 'nesting-bomb' && + // The *last* mutator of the chain, not a substring of it. `mutate` + // records `nesting-bomb → flip-bit`, and a substring test would excuse + // whatever the second mutator produced merely because a nesting bomb ran + // first. Reading the tail keeps that out while still covering + // `truncate → nesting-bomb`, where the bomb is what shaped the bytes. + lastMutator(divergence.input) === 'nesting-bomb' && // Narrow to the direction the reason argues: the bridge decoded an empty // message, upstream decoded a non-empty one. The reverse, and any // disagreement over a field both sides read, is not this and must still @@ -1506,21 +1696,6 @@ export const KNOWN_DIVERGENCES: readonly KnownDivergence[] = [ 'The bridge codec does not implement every message type the upstream protos declare (BotAvatarMetadata at the time of writing), and a field holding one is silently omitted rather than reported: MessageContextInfo{botMetadata:{avatarMetadata:{}}} encodes to 3a00 instead of 3a020a00. ALREADY TRACKED: BotAvatarMetadata is in KNOWN_UNSUPPORTED_CODECS and its fields in KNOWN_WIRE_GAPS in scripts/compatibility/proto-runtime-audit.ts. The unknown-type set here is probed at runtime rather than listed, so this entry stops matching by itself once the bridge implements them.', review: '2026-10-01' }, - { - id: 'proto-empty-string-for-numeric-field', - target: /^proto:/u, - status: 'open', - reason: - 'Given an empty string where the schema declares a 64-bit integer, the bridge coerces to 0 and protobufjs throws "empty string" — it routes 64-bit fields through Long.fromString, which rejects it. 32-bit fields are not affected: both sides coerce to 0 there, which is why the generator seeds the empty string into the 64-bit pools only. Same shape as the toNumber difference: baileyrs is the tolerant one. Tolerant is defensible, but it means a caller\'s type error is silently encoded as a real value instead of surfacing.', - review: '2026-11-01', - // The upstream error *and* what the bridge actually wrote. Keyed on the - // message alone, a regression that encoded the empty string as a nonzero - // value, or dropped the field, stayed green under a "coerces to 0" - // exception. A zero-valued 64-bit field encodes as the tag followed by a - // single `00`, or is omitted entirely when the field has no explicit - // presence — so those are the two outputs this accepts. - when: divergence => text(divergence.upstream).includes('empty string') && coercedAnEmptyString(divergence) - }, { id: 'poll-vote-aggregation-order', target: 'pure:getAggregateVotesInPollMessage', diff --git a/src/__fuzz__/harness/schema-context.ts b/src/__fuzz__/harness/schema-context.ts new file mode 100644 index 00000000..e579bb84 --- /dev/null +++ b/src/__fuzz__/harness/schema-context.ts @@ -0,0 +1,205 @@ +/** + * The schema a wire scan needs to read a payload the way a real decoder does. + * + * `wire.ts` states the rule this exists to serve: the wire format does not + * distinguish a nested message from a `bytes` field, so a scan without a schema + * has to guess, and *every caller that has a schema passes it*. This module is + * what makes that possible from more than one fuzzer — it used to live inside + * the codec differential, which is why the robustness fuzzer framed only the top + * level of a mutated payload and called a submessage corruption "well-formed". + * + * The schema records the repeated flag and the nested type but not the field + * *number*, so each number is recovered the way the field-number sweep recovers + * it: encode the field alone and read the tag back. Built per message, lazily, + * so a run only pays for the types it actually looks at. + */ + +import { encodeProto } from '@oxidezap/whatsapp-rust-bridge' +import { PROTO_FIELD_FLAG, PROTO_FIELD_KIND } from '../../WAProto/compatibility-schema.ts' +import { fieldsOfPath, messagePathOfField } from '../generators/proto.ts' +import type { SchemaContext } from './wire.ts' + +const upstream = (await import('baileys')) as unknown as { proto: Record } + +export interface UpstreamType { + encode(message: unknown): { finish(): Uint8Array } + decode(bytes: Uint8Array): unknown + toObject(message: unknown, options: Record): Record +} + +/** + * protobufjs namespaces nest, so a schema path is a lookup chain. + * + * The intermediate segments are *functions*, not objects: `proto.Message` is the + * generated Type constructor, and `Message.ExtendedTextMessage` hangs off it as a + * static. A `typeof === 'object'` guard here silently skips every nested type, + * which is most of the schema. + */ +export const upstreamType = (path: string): UpstreamType | undefined => { + let cursor: unknown = upstream.proto + for (const segment of path.split('.')) { + if (cursor === null || (typeof cursor !== 'object' && typeof cursor !== 'function')) return undefined + cursor = (cursor as Record)[segment] + } + const candidate = cursor as unknown as UpstreamType | undefined + return typeof cursor === 'function' && typeof candidate?.encode === 'function' ? candidate : undefined +} + +/** The field number of the first tag in a payload, or undefined if it does not parse. */ +export const firstFieldNumber = (bytes: Uint8Array): number | undefined => { + let result = 0n + let shift = 0n + for (let index = 0; index < bytes.length && index < 10; index++) { + const byte = bytes[index]! + result |= BigInt(byte & 0x7f) << shift + if ((byte & 0x80) === 0) { + const field = result >> 3n + return field >= 1n && field <= 536_870_911n ? Number(field) : undefined + } + shift += 7n + } + return undefined +} + +/** A non-default sample for a field kind, so the encode below emits the tag. */ +export const sampleFor = (kind: number): unknown => { + switch (kind) { + case PROTO_FIELD_KIND.string: + return 'x' + case PROTO_FIELD_KIND.bool: + return true + case PROTO_FIELD_KIND.bytes: + return new Uint8Array([1]) + default: + return 7 + } +} + +/** The kinds protobuf may pack, which is what makes a repeated field ambiguous. */ +const PACKABLE_KINDS: ReadonlySet = new Set([ + PROTO_FIELD_KIND.enum, + PROTO_FIELD_KIND.bool, + PROTO_FIELD_KIND.signed32, + PROTO_FIELD_KIND.unsigned32, + PROTO_FIELD_KIND.signed64, + PROTO_FIELD_KIND.unsigned64 +]) + +/** + * Per-message field-number metadata, for telling packing apart from a wrong wire + * type and a nested message apart from a `bytes` field. + * + * Field numbers are unique per message, not globally: this schema has 30 + * repeated scalar fields against 1734 singular ones drawing from the same small + * numbers, so a global set would answer "repeated" for nearly every singular + * field. + */ +interface FieldFacts { + readonly repeated: ReadonlySet + readonly messages: ReadonlyMap + /** Numbers two different fields claim — see `factsFor`. Empty across the schema today. */ + readonly contested: ReadonlySet +} + +const fieldFactsByPath = new Map() + +/** + * Every number a field is written under, asking both encoders rather than one. + * + * The two disagree on exactly one field out of 2421 — + * `Message.pollResultSnapshotMessageV3` is 114 upstream and 115 in the bridge — + * and the payloads these scans read are bridge-produced, so upstream's number + * alone leaves the bridge's spelling of that submessage looking like opaque + * bytes. Both are recorded because both are correct, each for the encoder that + * wrote the payload; nothing else in `Message` claims either number, on either + * side, so recording both cannot make a different field misframe. + * + * Measured rather than listed: a hardcoded exception would be exactly the + * hand-kept list the field-number sweep exists to replace. A field only one + * encoder can write (17 of them, all bridge-unwritable) keeps the one number + * that exists for it. + */ +const numbersFor = (path: string, type: UpstreamType | undefined, name: string, value: unknown): number[] => { + const numbers: number[] = [] + const record = (bytes: Uint8Array | undefined): void => { + const number = bytes === undefined ? undefined : firstFieldNumber(bytes) + if (number !== undefined && !numbers.includes(number)) numbers.push(number) + } + try { + record(type?.encode({ [name]: value }).finish()) + } catch { + /* the field is one this encoder refuses; the other may still write it */ + } + try { + record(encodeProto(path, { [name]: value })) + } catch { + /* same */ + } + return numbers +} + +/** + * Two encoders' numbers per field means a number could, in principle, be claimed + * by two different fields — and the scan would then frame a payload against + * whichever one it wrote down last. + * + * Measured across the whole schema: 2422 claims, no collisions, so the ambiguity + * is theoretical today. It stays checked rather than asserted in a comment + * because the thing that would create one is a schema regeneration, which is + * exactly the moment nobody re-reads this file. `harness.test.ts` fails on the + * first collision anywhere in the schema, which is the loud half. + * + * The quiet half is here: a contested number is recorded by *neither* field. + * The scan then treats it as opaque bytes — the answer it gave before this file + * knew about nested messages at all. Wrong-but-conservative beats confidently + * framing a payload against the wrong submessage, which is a finding reported at + * a location that does not exist. + */ +const factsFor = (path: string): FieldFacts => { + const cached = fieldFactsByPath.get(path) + if (cached) return cached + + const repeated = new Set() + const messages = new Map() + const claimant = new Map() + const contested = new Set() + const type = upstreamType(path) + if (type) { + for (const field of fieldsOfPath(path)) { + if ((field[3] & PROTO_FIELD_FLAG.map) !== 0) continue + const isMessage = field[1] === PROTO_FIELD_KIND.message + const one = isMessage ? {} : sampleFor(field[1]) + const isRepeated = (field[3] & PROTO_FIELD_FLAG.repeated) !== 0 + const nested = messagePathOfField(field) + for (const number of numbersFor(path, type, field[0], isRepeated ? [one] : one)) { + const prior = claimant.get(number) + if (prior !== undefined && prior !== field[0]) { + contested.add(number) + continue + } + claimant.set(number, field[0]) + if (isRepeated && PACKABLE_KINDS.has(field[1])) repeated.add(number) + if (nested !== undefined) messages.set(number, nested) + } + } + } + // After the sweep, not during: the first field to claim a number has already + // recorded it by the time the second one arrives. + for (const number of contested) { + repeated.delete(number) + messages.delete(number) + } + + const facts: FieldFacts = { repeated, messages, contested } + fieldFactsByPath.set(path, facts) + return facts +} + +/** The numbers more than one field claims on this message. Empty across the schema today. */ +export const contestedFieldNumbers = (path: string): readonly number[] => [...factsFor(path).contested] + +export const schemaAt = (path: string): SchemaContext => ({ + path, + isRepeated: (at, field) => factsFor(at).repeated.has(field), + messageAt: (at, field) => factsFor(at).messages.get(field) +}) diff --git a/src/__fuzz__/harness/wire.ts b/src/__fuzz__/harness/wire.ts index d420683f..5fc5dad6 100644 --- a/src/__fuzz__/harness/wire.ts +++ b/src/__fuzz__/harness/wire.ts @@ -175,7 +175,19 @@ const scanFrom = ( // changed value excused as a spelling difference. const child = descend(schema, field) const parseNestedHere = schema === undefined || child !== undefined - const nested = size > 0 && depth > 0 && parseNestedHere ? scan(slice, depth - 1, child) : undefined + const attempted = size > 0 && depth > 0 && parseNestedHere + const nested = attempted ? scan(slice, depth - 1, child) : undefined + // A field the schema *declares* a message, whose payload does not frame, + // makes the whole record malformed. Falling back to the raw hex is right + // without a schema — the bytes could be a string or a `bytes` field that + // merely looks like protobuf — but with one it called a payload + // well-formed whose submessage was corrupt, which is where a + // `lying-length` or a `flip-bit` usually lands. Measured: three of the + // four findings that motivated this framed at the top level and not + // inside, and belong to the interpretation class rather than the + // agreement one. `depth > 0` is part of `attempted` so exhausting the + // recursion budget is not mistaken for corruption. + if (attempted && child !== undefined && nested === undefined) return undefined const raw = Buffer.from(slice).toString('hex') fields.push({ field, diff --git a/src/__fuzz__/proto-codec.fuzz.test.ts b/src/__fuzz__/proto-codec.fuzz.test.ts index bdf10f81..a24db6db 100644 --- a/src/__fuzz__/proto-codec.fuzz.test.ts +++ b/src/__fuzz__/proto-codec.fuzz.test.ts @@ -30,11 +30,14 @@ import { isWireSubset, orderedWire, sameWireContent, - sameWireOrdering, - type SchemaContext + sameWireOrdering } from './harness/wire.ts' import { undoRenames, type Divergence } from './harness/divergence.ts' import { fuzz } from './harness/runner.ts' +import { firstFieldNumber, sampleFor, schemaAt, upstreamType, type UpstreamType } from './harness/schema-context.ts' + +/** The kinds protobufjs routes through `Long.fromString`, which rejects `''`. */ +const SIXTY_FOUR_BIT_KINDS: ReadonlySet = new Set([PROTO_FIELD_KIND.signed64, PROTO_FIELD_KIND.unsigned64]) import type { Random } from './harness/random.ts' import { PROTO_FIELD_FLAG, PROTO_FIELD_KIND } from '../WAProto/compatibility-schema.ts' import { @@ -50,14 +53,6 @@ import { type ProtoCase } from './generators/proto.ts' -const upstream = (await import('baileys')) as unknown as { proto: Record } - -interface UpstreamType { - encode(message: unknown): { finish(): Uint8Array } - decode(bytes: Uint8Array): unknown - toObject(message: unknown, options: Record): Record -} - /** * Rejects a candidate the shrinker invented that is not a valid case at all. * @@ -72,24 +67,6 @@ const isUsableCase = (value: ProtoCase): boolean => typeof value.message === 'object' && value.message !== null -/** - * protobufjs namespaces nest, so a schema path is a lookup chain. - * - * The intermediate segments are *functions*, not objects: `proto.Message` is the - * generated Type constructor, and `Message.ExtendedTextMessage` hangs off it as a - * static. A `typeof === 'object'` guard here silently skips every nested type, - * which is most of the schema — `resolves nested message types` pins that. - */ -const upstreamType = (path: string): UpstreamType | undefined => { - let cursor: unknown = upstream.proto - for (const segment of path.split('.')) { - if (cursor === null || (typeof cursor !== 'object' && typeof cursor !== 'function')) return undefined - cursor = (cursor as Record)[segment] - } - const candidate = cursor as unknown as UpstreamType | undefined - return typeof cursor === 'function' && typeof candidate?.encode === 'function' ? candidate : undefined -} - /** * The one shape both decoders can be compared in. * @@ -212,84 +189,6 @@ const combinedTag = ( /** Appends a classification the allowlist registry cannot compute for itself. */ const withTag = (detail: string, tag: string | undefined): string => (tag === undefined ? detail : `${detail} [${tag}]`) -/** - * The field kinds protobuf actually packs, and that unpack as varints. - * - * "Repeated" is not the same as "packable": a repeated string or bytes field is - * always one length-delimited entry per element and is never packed, so a - * wire-type change on one is a codec regression rather than a spelling - * difference. Floats are packable but fixed-width, so they never appear as the - * varint run this comparison looks for. - */ -/** The kinds protobufjs routes through `Long.fromString`, which rejects `''`. */ -const SIXTY_FOUR_BIT_KINDS: ReadonlySet = new Set([PROTO_FIELD_KIND.signed64, PROTO_FIELD_KIND.unsigned64]) - -const PACKABLE_KINDS: ReadonlySet = new Set([ - PROTO_FIELD_KIND.enum, - PROTO_FIELD_KIND.bool, - PROTO_FIELD_KIND.signed32, - PROTO_FIELD_KIND.unsigned32, - PROTO_FIELD_KIND.signed64, - PROTO_FIELD_KIND.unsigned64 -]) - -/** - * Per-message field-number metadata, for telling packing apart from a wrong wire - * type. - * - * `differsOnlyByPacking` cannot distinguish a one-element packed run from a - * singular scalar written length-delimited — the bytes are identical — so it asks - * the schema. Field numbers are unique per message, not globally, and this schema - * has 30 repeated scalar fields against 1734 singular ones drawing from the same - * small numbers: a global set would answer "repeated" for nearly every singular - * field and excuse exactly the regression this exists to catch. - * - * The compact schema records the repeated flag and the nested type but not the - * number, so each number is recovered the way the field-number sweep recovers it - * — encode the field alone, read the tag back. Built per message, lazily, so a - * run only pays for the types it actually compares. - */ -interface FieldFacts { - readonly repeated: ReadonlySet - readonly messages: ReadonlyMap -} - -const fieldFactsByPath = new Map() - -const factsFor = (path: string): FieldFacts => { - const cached = fieldFactsByPath.get(path) - if (cached) return cached - - const repeated = new Set() - const messages = new Map() - const type = upstreamType(path) - if (type) { - for (const field of fieldsOfPath(path)) { - if ((field[3] & PROTO_FIELD_FLAG.map) !== 0) continue - const isMessage = field[1] === PROTO_FIELD_KIND.message - const one = isMessage ? {} : sampleFor(field[1]) - const value = (field[3] & PROTO_FIELD_FLAG.repeated) !== 0 ? [one] : one - const encoded = attempt(() => type.encode({ [field[0]]: value }).finish()) - if (!encoded.ok) continue - const number = firstFieldNumber(encoded.value as Uint8Array) - if (number === undefined) continue - if ((field[3] & PROTO_FIELD_FLAG.repeated) !== 0 && PACKABLE_KINDS.has(field[1])) repeated.add(number) - const nested = messagePathOfField(field) - if (nested !== undefined) messages.set(number, nested) - } - } - - const facts: FieldFacts = { repeated, messages } - fieldFactsByPath.set(path, facts) - return facts -} - -const schemaAt = (path: string): SchemaContext => ({ - path, - isRepeated: (at, field) => factsFor(at).repeated.has(field), - messageAt: (at, field) => factsFor(at).messages.get(field) -}) - /** * Message types upstream declares that the bridge codec has never heard of. * @@ -1392,33 +1291,3 @@ function defaultFor(kind: number): unknown { return 0 } } - -/** The field number of the first tag in a payload, or undefined if it does not parse. */ -function firstFieldNumber(bytes: Uint8Array): number | undefined { - let result = 0n - let shift = 0n - for (let index = 0; index < bytes.length && index < 10; index++) { - const byte = bytes[index]! - result |= BigInt(byte & 0x7f) << shift - if ((byte & 0x80) === 0) { - const field = result >> 3n - return field >= 1n && field <= 536_870_911n ? Number(field) : undefined - } - shift += 7n - } - return undefined -} - -/** A non-default sample for a field kind, for the field-name sweep. */ -function sampleFor(kind: number): unknown { - switch (kind) { - case PROTO_FIELD_KIND.string: - return 'x' - case PROTO_FIELD_KIND.bool: - return true - case PROTO_FIELD_KIND.bytes: - return new Uint8Array([1]) - default: - return 7 - } -} diff --git a/src/__fuzz__/proto-robustness.fuzz.test.ts b/src/__fuzz__/proto-robustness.fuzz.test.ts index 1c9492e1..0a92c0e2 100644 --- a/src/__fuzz__/proto-robustness.fuzz.test.ts +++ b/src/__fuzz__/proto-robustness.fuzz.test.ts @@ -28,6 +28,7 @@ import { describe, it } from 'node:test' import { decodeProto, encodeProto } from '@oxidezap/whatsapp-rust-bridge' import { equivalent, normalise } from './harness/compare.ts' import { canonicalWire } from './harness/wire.ts' +import { schemaAt } from './harness/schema-context.ts' import { makeRandom, type Random } from './harness/random.ts' import { fuzz } from './harness/runner.ts' import { generateProtoObject, textFieldPredicate, HOT_PROTO_PATHS } from './generators/proto.ts' @@ -265,7 +266,16 @@ describe('protobuf decoder robustness under mutation', () => { // differently is a bug. Bytes that do not frame as protobuf at all have // no defined meaning, so disagreement there is a strictness difference // and is reported separately. - const wellFormed = canonicalWire(bytes) !== undefined + // + // With the schema, not without. Unschooled, the scan cannot tell a + // nested message from a `bytes` field, so it frames only the top level + // and calls a payload well-formed whose *submessage* is corrupt — which + // is where a `lying-length` or a `flip-bit` usually lands. Measured on + // the four findings this fixed: three framed at the top and not inside, + // and belong to the interpretation class the entry beside this one + // already calls undefined behaviour. `wire.ts` states the rule — every + // caller that has a schema passes it — and this one has `path`. + const wellFormed = canonicalWire(bytes, schemaAt(path)) !== undefined return { target: wellFormed ? 'proto:mutation-agreement' : 'proto:mutation-interpretation', input: { path, mutator, bytes: hex(bytes) }, diff --git a/src/__tests__/encode-proto-compat.test.ts b/src/__tests__/encode-proto-compat.test.ts new file mode 100644 index 00000000..0d5749e1 --- /dev/null +++ b/src/__tests__/encode-proto-compat.test.ts @@ -0,0 +1,118 @@ +/** + * The boundary that keeps this library tolerant of two inputs the bridge codec + * refuses from 0.8.0 on. + * + * Both were written before — an empty string in a 64-bit field as `0`, an + * unpaired surrogate as U+FFFD — and upstream Baileys still encodes both, so + * passing the refusal through would break the send path for callers. The bytes + * asserted below are written out by hand rather than derived from the codec: + * deriving them would make the test agree with whatever the codec does. + */ + +import { Buffer } from 'node:buffer' +import { describe, it } from 'node:test' +import { encodeProtoCompat } from '../Compatibility/encode-proto.ts' +import { repairProtoMessage } from '../Compatibility/proto-runtime.ts' +import { proto } from '../WAProto/runtime.ts' +import { expect } from './expect.ts' + +const hex = (bytes: Uint8Array) => Buffer.from(bytes).toString('hex') + +describe('encodeProtoCompat — inputs the bridge codec stopped accepting', () => { + it('writes 0 for an empty string in a 64-bit field', () => { + // Message.stickerMessage = field 26 (d201), one nested byte for fileLength + // = field 9 (48) holding 0. + expect(hex(encodeProtoCompat('Message', { stickerMessage: { fileLength: '' } }))).toBe('d201024800') + }) + + it('substitutes U+FFFD for an unpaired surrogate in a text field', () => { + // conversation = field 1 (0a), length 3, then the UTF-8 for U+FFFD. + expect(hex(encodeProtoCompat('Message', { conversation: '\udfff' }))).toBe('0a03efbfbd') + }) + + it('leaves a message the codec accepts exactly as it was', () => { + expect(hex(encodeProtoCompat('Message', { conversation: 'hi' }))).toBe('0a026869') + }) + + /** + * The repair is not a licence to write any number at all. A string that is + * merely not a number was never accepted, and coercing it would put a value on + * the wire that nobody sent — the failure mode the bridge's stricter contract + * exists to prevent. + */ + it('still refuses a value that was never accepted', () => { + expect(() => encodeProtoCompat('Message', { stickerMessage: { fileLength: 'abc' } })).toThrow() + expect(() => encodeProtoCompat('Message', { stickerMessage: { fileLength: 1.5 } })).toThrow() + }) + + it('propagates a failure it does not explain, rather than retrying into a second throw', () => { + // An unknown type never reaches the repair: there is no schema to walk, so + // the original error is what the caller sees. + expect(() => encodeProtoCompat('NoSuchMessageType', { a: 1 })).toThrow() + }) + + it('repairs a field nested several messages deep', () => { + const bytes = encodeProtoCompat('Message', { + ephemeralMessage: { message: { stickerMessage: { fileLength: '', mimetype: 'x' } } } + }) + // The sticker survives with a mimetype, so the message was rebuilt rather + // than emptied on the way through. + const decoded = proto.Message.decode(bytes) + expect(decoded.ephemeralMessage?.message?.stickerMessage?.mimetype).toBe('x') + }) + + it('repairs every element of a repeated field', () => { + const repaired = repairProtoMessage('Message.ListMessage', { + sections: [{ title: '\udfff' }, { title: 'ok' }, { title: '\ud800' }] + }) as { sections: { title: string }[] } + expect(repaired.sections.map(section => section.title)).toEqual(['�', 'ok', '�']) + }) +}) + +describe('repairProtoMessage — copy-on-write contract', () => { + it('returns the same reference when there is nothing to repair', () => { + const message = { conversation: 'hi' } + expect(repairProtoMessage('Message', message)).toBe(message) + }) + + it('returns the same reference for a type it has no schema for', () => { + const message = { anything: 1 } + expect(repairProtoMessage('NoSuchMessageType', message)).toBe(message) + }) + + it('does not mutate the caller’s message', () => { + const message = { conversation: '\udfff' } + const repaired = repairProtoMessage('Message', message) as { conversation: string } + expect(message.conversation).toBe('\udfff') + expect(repaired.conversation).toBe('�') + }) + + it('shares the branches it did not touch', () => { + const untouched = { url: 'https://example.test' } + const message = { conversation: '\udfff', imageMessage: untouched } + const repaired = repairProtoMessage('Message', message) as { imageMessage: unknown } + // Copy-on-write: only the branch that changed is rebuilt. + expect(repaired.imageMessage).toBe(untouched) + }) +}) + +describe('proto.X.encode — the same repair through the facade', () => { + /** + * The facade hangs the retry off `finish` rather than `encode`, because the + * bridge's writer is lazy: `encode` queues the fields and `finish` is what + * writes them, so a refused value surfaces there. A test that only called + * `encode()` would pass against a facade that never repairs anything. + */ + it('repairs through the lazy writer, at finish rather than encode', () => { + const writer = proto.Message.encode({ stickerMessage: { fileLength: '' as never } }) + expect(hex(writer.finish())).toBe('d201024800') + }) + + it('substitutes an unpaired surrogate through the facade too', () => { + expect(hex(proto.Message.encode({ conversation: '\udfff' }).finish())).toBe('0a03efbfbd') + }) + + it('leaves an acceptable message untouched through the facade', () => { + expect(hex(proto.Message.encode({ conversation: 'hi' }).finish())).toBe('0a026869') + }) +}) diff --git a/src/__tests__/regressions.test.ts b/src/__tests__/regressions.test.ts index dd847f91..25b0d9a3 100644 --- a/src/__tests__/regressions.test.ts +++ b/src/__tests__/regressions.test.ts @@ -333,6 +333,59 @@ describe('adapter: pair_error', () => { }) }) +describe('adapter: pairing_code_error', () => { + const evt = (data: Record) => ({ type: 'pairing_code_error', data }) + + it('lands on the same canonical event a pair_error does', () => { + expect(adapt(evt({ error: 'rejected', rejection: 3, backoff: 300 }), 'pairError')).toEqual({ + type: 'pairError', + error: 'rejected', + rejection: 3, + backoff: 300 + }) + }) + + it('leaves rejection and backoff absent when the server named neither', () => { + const c = adapt(evt({ error: 'no connection' }), 'pairError') + expect(c.rejection).toBeUndefined() + expect(c.backoff).toBeUndefined() + }) + + // The spent code is displayed through `connection.update.qr` (a pairing code + // surfaces as a QR does), so `connecting` with an explicit `qr: undefined` + // is what stops it being offered. Dropping the event left it on screen. + it('clears the spent code and keeps the socket open', () => { + const updates = collect(evt({ error: 'rejected' }), 'connection.update') + expect(updates).toEqual([{ connection: 'connecting', qr: undefined, receivedPendingNotifications: false }]) + }) +}) + +describe('adapter: disable_link_previews_update', () => { + const evt = (data: Record) => ({ type: 'disable_link_previews_update', data }) + + it('carries the action through as upstream does', () => { + expect(adapt(evt({ previews_disabled: true, action: { isPreviewsDisabled: true } }), 'settingUpdate')).toEqual({ + type: 'settingUpdate', + setting: 'disableLinkPreviews', + value: { isPreviewsDisabled: true } + }) + }) + + // The flag the bridge decoded is the same bit; it fills the action in rather + // than handing consumers a value missing the only field they read. + it('falls back to the decoded flag when the action omits it', () => { + expect(adapt(evt({ previews_disabled: true, action: {} }), 'settingUpdate').value).toEqual({ + isPreviewsDisabled: true + }) + }) + + it('emits on upstream own settings.update channel', () => { + expect( + collect(evt({ previews_disabled: false, action: { isPreviewsDisabled: false } }), 'settings.update') + ).toEqual([{ setting: 'disableLinkPreviews', value: { isPreviewsDisabled: false } }]) + }) +}) + describe('adapter: connect_failure', () => { it('captures the numeric reason code', () => { expect(