From f0ee205f96e34beefed2db54ac9e5294f301a4ce Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Sat, 25 Apr 2026 15:06:16 -0300 Subject: [PATCH] fix(waproto): normalize high to int32 + tighten roundtrip test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses 2 review comments on PR #385: 1. CodeRabbit Major — longToString/longToNumber checked `value.high < 0` on the raw input. When a Long-like object carries high as an unsigned 32-bit value (e.g. raw {low, high} from JSON deserialization, not constructed via Long.fromBits/fromValue which normalize via `| 0`), the sign-bit detection fails and the value is interpreted as unsigned. Example: {low: 0, high: 0xFFFFFFFF} signed should be -4294967296 but returned 18446744069414584320. Fix: `const high = value.high | 0` before the sign check. Applied to both helpers in WAProto/index.js (generated artifact) and WAProto/fix-imports.js (template). Diverges from upstream Baileys PR #2333 which has the same latent bug. Added 2 test cases in bigint-validation.test.ts for the raw {low, high} scenario (Long.fromBits would mask the bug because it normalizes internally). 2. CodeRabbit Nitpick — the encode/decode roundtrip test in proto-tojson-long.test.ts used safe integers (1700000000 and 123456789), which would silently pass even if longToString fell back to Number(value). Switched to uint64 values above MAX_SAFE_INTEGER (max uint64 and 9999999999999999999) so the roundtrip actually exercises the BigInt fast path. Test suite: 789/789 passing (+2 cases for the unsigned-high scenario). Co-Authored-By: Claude Opus 4.7 (1M context) --- WAProto/fix-imports.js | 8 ++++++-- WAProto/index.js | 8 ++++++-- src/__tests__/bigint-validation.test.ts | 27 +++++++++++++++++++++++-- src/__tests__/proto-tojson-long.test.ts | 14 ++++++++----- 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/WAProto/fix-imports.js b/WAProto/fix-imports.js index 49723b3d..0c8f3c8d 100644 --- a/WAProto/fix-imports.js +++ b/WAProto/fix-imports.js @@ -21,10 +21,13 @@ try { '\t// Fast path: convert Long {low, high} directly via native BigInt\n' + '\t// BigInt.toString() is a native C++ operation, much faster than Long\'s pure JS division loops\n' + '\tif (value && typeof value.low === "number" && typeof value.high === "number") {\n' + + '\t\t// Normalize high to signed int32 so the sign-bit check works for inputs\n' + + '\t\t// where high is stored unsigned (e.g. raw {low, high} JSON).\n' + + '\t\tconst high = value.high | 0;\n' + '\t\tconst lo = BigInt(value.low >>> 0);\n' + '\t\tconst hi = BigInt(value.high >>> 0);\n' + '\t\tconst combined = (hi << 32n) | lo;\n' + - '\t\tif (!unsigned && value.high < 0) {\n' + + '\t\tif (!unsigned && high < 0) {\n' + '\t\t\treturn (combined - (1n << 64n)).toString();\n' + '\t\t}\n' + '\t\treturn combined.toString();\n' + @@ -41,10 +44,11 @@ try { '\t}\n' + '\t// Fast path: convert Long {low, high} directly via native BigInt\n' + '\tif (value && typeof value.low === "number" && typeof value.high === "number") {\n' + + '\t\tconst high = value.high | 0;\n' + '\t\tconst lo = BigInt(value.low >>> 0);\n' + '\t\tconst hi = BigInt(value.high >>> 0);\n' + '\t\tconst combined = (hi << 32n) | lo;\n' + - '\t\tif (!unsigned && value.high < 0) {\n' + + '\t\tif (!unsigned && high < 0) {\n' + '\t\t\treturn Number(combined - (1n << 64n));\n' + '\t\t}\n' + '\t\treturn Number(combined);\n' + diff --git a/WAProto/index.js b/WAProto/index.js index 56bb9189..ae2340f2 100644 --- a/WAProto/index.js +++ b/WAProto/index.js @@ -15,10 +15,13 @@ function longToString(value, unsigned) { // Fast path: convert Long {low, high} directly via native BigInt // BigInt.toString() is a native C++ operation, much faster than Long's pure JS division loops if (value && typeof value.low === "number" && typeof value.high === "number") { + // Normalize high to signed int32 so the sign-bit check works for inputs + // where high is stored unsigned (e.g. raw {low, high} JSON). + const high = value.high | 0; const lo = BigInt(value.low >>> 0); const hi = BigInt(value.high >>> 0); const combined = (hi << 32n) | lo; - if (!unsigned && value.high < 0) { + if (!unsigned && high < 0) { return (combined - (1n << 64n)).toString(); } return combined.toString(); @@ -35,10 +38,11 @@ function longToNumber(value, unsigned) { } // Fast path: convert Long {low, high} directly via native BigInt if (value && typeof value.low === "number" && typeof value.high === "number") { + const high = value.high | 0; const lo = BigInt(value.low >>> 0); const hi = BigInt(value.high >>> 0); const combined = (hi << 32n) | lo; - if (!unsigned && value.high < 0) { + if (!unsigned && high < 0) { return Number(combined - (1n << 64n)); } return Number(combined); diff --git a/src/__tests__/bigint-validation.test.ts b/src/__tests__/bigint-validation.test.ts index a0938304..0559936a 100644 --- a/src/__tests__/bigint-validation.test.ts +++ b/src/__tests__/bigint-validation.test.ts @@ -19,10 +19,11 @@ function longToStringNew(value: any, unsigned?: boolean): string { if (typeof value === 'string') return value if (typeof value === 'number') return String(value) if (value && typeof value.low === 'number' && typeof value.high === 'number') { + const high = value.high | 0 const lo = BigInt(value.low >>> 0) const hi = BigInt(value.high >>> 0) const combined = (hi << 32n) | lo - if (!unsigned && value.high < 0) { + if (!unsigned && high < 0) { return (combined - (1n << 64n)).toString() } @@ -50,10 +51,11 @@ function longToNumberNew(value: any, unsigned?: boolean): number { if (typeof value === 'number') return value if (typeof value === 'string') return Number(value) if (value && typeof value.low === 'number' && typeof value.high === 'number') { + const high = value.high | 0 const lo = BigInt(value.low >>> 0) const hi = BigInt(value.high >>> 0) const combined = (hi << 32n) | lo - if (!unsigned && value.high < 0) { + if (!unsigned && high < 0) { return Number(combined - (1n << 64n)) } @@ -158,6 +160,27 @@ describe('BigInt vs Long equivalence validation', () => { it('object without low/high', () => { expect(longToStringNew({ foo: 'bar' })).toBe('[object Object]') }) + + // Defensive: a raw {low, high} JSON object can carry `high` as an + // unsigned 32-bit value (Long.fromBits would normalize via `| 0`, + // but plain JSON deserialization does not). Without `value.high | 0` + // the sign-bit check fails and the result is interpreted as unsigned. + // Upstream Baileys PR #2333 has this latent bug; InfiniteAPI fixes it. + it('signed sign-bit detection on raw unsigned high (-1 as {low: -1, high: 4294967295})', () => { + const raw = { low: -1, high: 0xffffffff } + // signed: -1 + expect(longToStringNew(raw, false)).toBe('-1') + // unsigned: max uint64 + expect(longToStringNew(raw, true)).toBe('18446744073709551615') + }) + + it('signed sign-bit detection on raw unsigned high (-4294967296 as {low: 0, high: 4294967295})', () => { + const raw = { low: 0, high: 0xffffffff } + // signed: -4294967296 (high word = -1 after normalization) + expect(longToStringNew(raw, false)).toBe('-4294967296') + // unsigned: 0xFFFFFFFF00000000 = 18446744069414584320 + expect(longToStringNew(raw, true)).toBe('18446744069414584320') + }) }) describe('fuzz: random Long values', () => { diff --git a/src/__tests__/proto-tojson-long.test.ts b/src/__tests__/proto-tojson-long.test.ts index 0c48dfac..cf0072df 100644 --- a/src/__tests__/proto-tojson-long.test.ts +++ b/src/__tests__/proto-tojson-long.test.ts @@ -90,17 +90,21 @@ describe('proto serialization', () => { expect(json.message?.imageMessage?.fileLength).toBe('1') }) - it('roundtrips encode/decode with Long fields preserving values', () => { + it('roundtrips encode/decode with Long fields preserving values (> MAX_SAFE_INTEGER)', () => { + // Use uint64 values above Number.MAX_SAFE_INTEGER (2^53 - 1) so the + // roundtrip actually exercises the BigInt fast path on decode->toJSON. + // Safe integers would silently work even if longToString fell back to + // Number(value). const original = proto.WebMessageInfo.fromObject({ key: { remoteJid: '123@s.whatsapp.net', id: 'ABC123', fromMe: false }, - messageTimestamp: 1700000000, + messageTimestamp: Long.fromString('18446744073709551615', true), message: { imageMessage: { - fileLength: 123456789 + fileLength: Long.fromString('9999999999999999999', true) } } }) @@ -109,7 +113,7 @@ describe('proto serialization', () => { const decoded = proto.WebMessageInfo.decode(encoded) const json = decoded.toJSON() - expect(json.messageTimestamp).toBe('1700000000') - expect(json.message?.imageMessage?.fileLength).toBe('123456789') + expect(json.messageTimestamp).toBe('18446744073709551615') + expect(json.message?.imageMessage?.fileLength).toBe('9999999999999999999') }) })