Skip to content

LongBits.fromBigInt drops the two's-complement carry: negative int64/sint64/sfixed64 values below −(2^53−1) with zero low 32 bits encode as value − 2^32 #214

Open

Description

@Brijesh-Thakkar

Follow-up to the note in #212 ("INT64_MIN is mis-encoded on int64/sint64/sfixed64 … separate overflow in fromBigInt"). The same overflow is not limited to INT64_MIN. It affects every negative value below Number.MIN_SAFE_INTEGER whose low 32 bits are zero, for example -(2n ** 53n), -(2n ** 60n) and -(2n ** 63n - 2n ** 32n).

The output is valid protobuf, so nothing flags it. A protons round trip silently returns value - 2^32. For INT64_MIN the result wraps to a positive number.

  • affected: int64, sint64, sfixed64 (encoding only)
  • not affected: decoding (correct bytes decode to the correct value), and values inside the safe-integer range (they go through fromNumber)

Repro

node --input-type=module -e '
import { writer, reader } from "protons-runtime"
const v = -(2n ** 53n)
const w = writer(); w.int64(v); const bytes = w.finish()
console.log(Buffer.from(bytes).toString("hex"), reader(bytes).int64())'
# protons-runtime 8.0.1 and main (e6eb6bf):
# 80808080f0ffffefff01 -9007203549708288n
# expected:
# 80808080808080f0ff01 -9007199254740992n

Actual vs expected bytes (main @ e6eb6bf, and identical on 8.0.1):

value codec actual expected
−2^53 int64 80808080f0ffffefff01 80808080808080f0ff01
−2^53 sint64 ffffffff9f808020 ffffffffffffff1f
−2^53 sfixed64 00000000ffffdfff 000000000000e0ff
−2^63 int64 80808080f0ffffff7f (9 bytes) 80808080808080808001
−2^63 sint64 80808080e0ffffffff01 ffffffffffffffffff01
−2^63 sfixed64 00000000ffffff7f 0000000000000080
−(2^53+1) (control) int64 ffffffffffffffefff01 same ✔

What a protons round trip returns:

encoded decodes as difference
−2^53 −2^53 − 2^32 −2^32
−2^60 −2^60 − 2^32 −2^32
−(2^63 − 2^32) −2^63 −2^32
−2^63 +(2^63 − 2^32) wraps

A self-contained node:test file (negative-carry.test.mjs, attached below) covers all three codecs, encode and decode, with 5 affected values and 4 controls, checked against a BigInt reference encoder written from the spec.

negative-carry.test.mjs
// protons-runtime: negative 64-bit values below -(2^53 - 1) whose low 32 bits
// are zero are mis-encoded by int64, sint64 and sfixed64.
// Run: node --test negative-carry.test.mjs
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { writer, reader } from 'protons-runtime'

const TWO_64 = 1n << 64n

// reference encodings, straight from the protobuf spec
function varint (u) {
  const out = []
  do {
    let b = Number(u & 0x7fn)
    u >>= 7n
    if (u > 0n) b |= 0x80
    out.push(b)
  } while (u > 0n)
  return out
}
const expected = {
  int64: (v) => varint(v < 0n ? v + TWO_64 : v),
  sint64: (v) => varint(v >= 0n ? v << 1n : (-v << 1n) - 1n),
  sfixed64: (v) => {
    const b = Buffer.alloc(8)
    b.writeBigInt64LE(v)
    return [...b]
  }
}
const hex = (bytes) => Buffer.from(bytes).toString('hex')

const P = (n) => 1n << BigInt(n)
const cases = [
  // low 32 bits zero: affected
  [-P(53), true],
  [-(P(53) + P(32)), true],
  [-P(60), true],
  [-(P(63) - P(32)), true],
  [-P(63), true], // INT64_MIN
  // controls: low 32 bits non-zero, or within the safe-integer range
  [-(P(53) + 1n), false],
  [-(P(53) - 1n), false],
  [-P(32), false],
  [-(P(63) - 1n), false]
]

for (const method of ['int64', 'sint64', 'sfixed64']) {
  for (const [value, affected] of cases) {
    test(`${method} encode ${value}${affected ? '' : ' (control)'}`, () => {
      const w = writer()
      w[method](value)
      assert.equal(hex(w.finish()), hex(expected[method](value)))
    })

    test(`${method} decode ${value}${affected ? '' : ' (control)'}`, () => {
      assert.equal(reader(Uint8Array.from(expected[method](value)))[method](), value)
    })
  }
}

Cause

packages/protons-runtime/src/utils/longbits.ts, fromBigInt:

if (negative) {
  hi = ~hi | 0n
  lo = ~lo | 0n

  if (++lo > TWO_32) {      // L138
    lo = 0n
    if (++hi > TWO_32) { hi = 0n }
  }
}

These are BigInts, so nothing wraps at 32 bits. When the magnitude's low word is 0n, ~0n is -1n and ++lo is 0n. That is never > TWO_32, so the carry into hi is skipped and the high word comes out one too small. When lo != 0n, ~lo + 1n is simply -lo, which >>> 0 in the constructor maps to the right word, so those values are fine.

This came in with the TS port in d101804 (#60). protons-runtime 5.0.3 (protobufjs) passes all cases, and 5.0.4 is the first failing release. In 5.0.4, −2^32 also failed, presumably because it predates the fromNumber fast path for safe integers (ba35475).

Possible fix

Take the two's complement in one step:

const u = BigInt.asUintN(64, value)
return new LongBits(Number(u & 0xffffffffn), Number(u >> 32n))

This replaces everything after the safe-integer fast path. Checked locally against main @ e6eb6bf (patched build output only): the repro goes from 15 failures to 54/54, and packages/protons aegir test -t node passes (73 passing, including the #212 64-bit varints tests). I haven't run the browser targets. Happy to open a PR with a regression test if that's welcome.

Versions

protons-runtime result (54 tests)
main @ e6eb6bf (contains #212) 15 fail (all encode cases for the 5 affected values × 3 codecs)
8.0.1 15 fail (same set)
7.1.2 15 fail (same set)
5.0.4 18 fail (same set, plus −2^32)
5.0.3 0 fail

Node v22.23.1, Linux x64.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions