Skip to content

fix: correct carry for large negative 64-bit values - #215

Open
Brijesh-Thakkar wants to merge 1 commit into
ipfs:mainfrom
Brijesh-Thakkar:fix/int64-negative-carry
Open

Brijesh-Thakkar wants to merge 1 commit into
ipfs:mainfrom
Brijesh-Thakkar:fix/int64-negative-carry

Conversation

@Brijesh-Thakkar

Copy link
Copy Markdown

Fixes #214

What's wrong

LongBits.fromBigInt negates the magnitude word by word using BigInts:

hi = ~hi | 0n
lo = ~lo | 0n

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

BigInts don't wrap at 32 bits. When the low word is 0n, ~0n + 1n is 0n, not 2^32, so the carry into hi is never taken. Every negative value below Number.MIN_SAFE_INTEGER whose low 32 bits are zero is affected, for example −2^53, −2^60, −(2^63 − 2^32) and INT64_MIN. These encode as value − 2^32 in int64, sint64 and sfixed64. The output is valid protobuf, so nothing flags it, and a round trip silently returns the wrong value. Values inside the safe-integer range take the fromNumber path and are unaffected, and so is decoding.

This is the "separate overflow in fromBigInt" noted in #212. It covers more than INT64_MIN.

Minimal 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())'
# before: 80808080f0ffffefff01 -9007203549708288n
# after:  80808080808080f0ff01 -9007199254740992n

Fix

Take the two's complement in one step:

const bits = BigInt.asUintN(64, value)

return new LongBits(Number(bits & 0xffffffffn), Number(bits >> 32n))

TWO_32 was only used there, so it's removed.

Tests

protons-runtime didn't have its own test suite yet, so this adds packages/protons-runtime/test/longbits.spec.ts plus the standard aegir test* scripts to its package.json. The new tests cover int64, sint64 and sfixed64 for 5 affected values and 1 control (−(2^53 + 1)), checking both the exact encoded bytes and the round trip. The expected bytes come from an independent BigInt encoder written from the protobuf spec. If you'd rather keep these next to the #212 tests in packages/protons/test/longbits.spec.ts, I'm happy to move them.

Local results (Node v22.23.1, Linux x64):

before fix after fix
new spec (mocha, no --bail) 6 passing, 30 failing 36 passing
npm run test:node (all packages) protons-runtime 36 passing, protons 73 passing
test:chrome, test:chrome-webworker, test:firefox, test:firefox-webworker 36 passing each
test:electron-main 36 passing
build, lint, dep-check, doc-check, spell-check pass

Release note

The 8.0.2 release from #212 hasn't reached npm yet: the tag and release commit exist, but run 38750177343 failed before publishing. If 8.0.2 is going to be re-released, it would be good to include this fix alongside #212, since both are 64-bit encoding fixes in LongBits that have been present since 5.0.4.

LongBits.fromBigInt negated the magnitude word by word with BigInts,
where ~0n + 1n is 0n rather than 2^32, so the carry into the high word
was never taken. Negative values below -(2^53 - 1) whose low 32 bits are
zero (e.g. -2^53, -2^60, INT64_MIN) were encoded as value - 2^32 by
int64, sint64 and sfixed64.

Take the two's complement with BigInt.asUintN(64, value) instead, and
add regression tests to protons-runtime along with its test scripts.

Fixes ipfs#214
Copilot AI balanced review requested due to automatic review settings October 23, 2026 14:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

2 participants