fix(graph-ts): catch i32/i64 overflow when the sign bit is the widest bit - #2155
Open
pucedoteth wants to merge 1 commit into
Open
fix(graph-ts): catch i32/i64 overflow when the sign bit is the widest bit#2155pucedoteth wants to merge 1 commit into
pucedoteth wants to merge 1 commit into
Conversation
… bit
toI32 checks that every byte above the four it keeps matches the sign
padding, and toI64 does the same above eight:
const isNeg = this.length > 0 && this[this.length - 1] >> 7 == 1;
const padding = isNeg ? 255 : 0;
for (let i = 4; i < this.length; i++) {
if (this[i] != padding) {
assert(false, 'overflow converting ... to i32');
}
}
That is not sufficient. A positive value whose top kept byte has its high
bit set is one bit too wide for the signed window, yet the bytes above it
are zero and match the padding, so the loop passes and the value is
reinterpreted as negative. Both methods document "Throws in case of
overflow" and neither does here.
The shape is not exotic. BigInt.fromUnsignedBytes appends a zero byte, so
any u32 at or above 2^31 becomes five bytes with a zero on top:
BigInt.fromUnsignedBytes(0x80000000) -> [0, 0, 0, 128, 0]
.toI32() == -2147483648 // silently, no throw
isI32() already reports false for that same value, so the library knows it
does not fit; only the conversion does not check.
Also require the sign bit of the last byte inside the window to agree with
the padding. Values that genuinely fit are untouched, including the widened
encodings: i32.MAX as [255,255,255,127,0] and i32.MIN as [0,0,0,128,255]
both still convert, and a four byte array is a plain i32 and never
consults the new check.
Two existing assertions asserted the wrapped result:
assert(longArray.toI32() == 4_294_705_147);
4_294_705_147 does not fit an i32; the literal truncates to -262_149, which
is what toI32 returned, so the assertion passed while reading as though the
full value came back. Both copies are replaced with a note, because an
overflow aborts and this harness cannot express an expected abort.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 3672ec7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
ByteArray.toI32checks that every byte above the four it keeps matches the sign padding, andtoI64does the same above eight:That is not sufficient. A positive value whose top kept byte has its high bit set is one bit too wide for the signed window — but the bytes above it are zero and match the padding, so the loop passes and the value comes back reinterpreted as negative. Both methods document "Throws in case of overflow", and neither does here.
The shape is not exotic:
BigInt.fromUnsignedBytesappends a zero byte, so anyu32at or above2^31lands in exactly this state.For a subgraph that means a large
uint32— a counter, a packed field, a timestamp — is written to the store as a negative number with nothing raised.isI32()reportsfalsefor the same value, so the range check exists; only the conversion skips it.The fix
Also require the sign bit of the last byte inside the window to agree with the padding.
Values that genuinely fit are untouched, including the widened encodings:
[255,255,255,127][0,0,0,128][255,255,255,127,0][0,0,0,128,255][0,0,0,128,0][251,255,251,255,0]A four-byte array is a plain
i32and never consults the new check, so the common path is unchanged.toI64gets the same treatment at byte 7.This is a behavior change — worth your call
Values that silently wrapped now abort the mapping. I think that is right: it is the documented contract,
toU32already throws for its own range, and a negative count in the store is worse than a loud failure. But it will stop a subgraph that is currently indexing wrapped values, so it is your call whether this rides a patch or waits for a major. Happy to gate it or downgrade it to alog.warninginstead if you would rather not throw.About the tests
Two existing assertions asserted the wrapped result, in
test/bytes.tsand again intest/bigInt.ts:4_294_705_147does not fit ani32; the literal truncates to-262_149, which is exactly whattoI32returned — so the assertion passed while reading as though the full value came back. Both copies are replaced with a note explaining why the value now overflows.I could not add a positive "it throws" test: an overflow aborts, and this harness has no way to expect an abort. Worth knowing that this is pre-existing — I checked the already-shipped overflow path (a byte above the window that does not match the padding) on unmodified code and it surfaces the same way, as
RuntimeError: memory access out of bounds, because the assert message callstoHexString()and the harness stubs that host function to a no-op. My change behaves identically to the existing overflow path.What I did verify, by running it:
ByteArrayandBigIntpnpm run test:ts— full suite greenpnpm run lint— prettier and eslint cleanChangeset included.
🤖 Written with Claude Code. All results above are from a local run.