diff --git a/CHANGELOG.md b/CHANGELOG.md index ec7adda..e7ff75e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ project adheres to [Semantic Versioning](http://semver.org/). * Decoding Array and VarArray now fast fails when the array length exceeds remaining bytes to decode ([#132](https://github.com/stellar/js-xdr/pull/132)) +* Fixed silent truncation of bigint values exceeding the range of sized integers (`Hyper`, `UnsignedHyper`, and other `LargeInt` subtypes). Construction, encoding, and multi-part assembly now throw on overflow/underflow instead of silently clamping. `isValid` also validates value range. `sliceBigInt` now returns unsigned slice values for consistency ([#133](https://github.com/stellar/js-xdr/pull/133)). + + + + ## [v3.1.2](https://github.com/stellar/js-xdr/compare/v3.1.1...v3.1.2) ### Fixed diff --git a/src/bigint-encoder.js b/src/bigint-encoder.js index 861096d..defa6c4 100644 --- a/src/bigint-encoder.js +++ b/src/bigint-encoder.js @@ -43,20 +43,32 @@ export function encodeBigIntFromBits(parts, size, unsigned) { throw new TypeError(`expected bigint-like values, got: ${parts} (${e})`); } - // check for sign mismatches for single inputs (this is a special case to - // handle one parameter passed to e.g. UnsignedHyper et al.) - // see https://github.com/stellar/js-xdr/pull/100#discussion_r1228770845 - if (unsigned && parts.length === 1 && parts[0] < 0n) { - throw new RangeError(`expected a positive value, got: ${parts}`); + // fast path: single value — validate and return directly without assembly + if (parts.length === 1) { + const value = parts[0]; + if (unsigned && value < 0n) { + throw new RangeError(`expected a positive value, got: ${parts}`); + } + const [min, max] = calculateBigIntBoundaries(size, unsigned); + if (value < min || value > max) { + throw new TypeError( + `bigint value ${value} for ${formatIntName( + size, + unsigned + )} out of range [${min}, ${max}]` + ); + } + return value; } - // encode in big-endian fashion, shifting each slice by the slice size - let result = BigInt.asUintN(sliceSize, parts[0]); // safe: len >= 1 - for (let i = 1; i < parts.length; i++) { + // multi-part assembly: encode in big-endian fashion, shifting each slice + let result = 0n; + + for (let i = 0; i < parts.length; i++) { + assertSliceFits(parts[i], sliceSize); result |= BigInt.asUintN(sliceSize, parts[i]) << BigInt(i * sliceSize); } - // interpret value as signed if necessary and clamp it if (!unsigned) { result = BigInt.asIntN(size, result); } @@ -139,3 +151,21 @@ export function calculateBigIntBoundaries(size, unsigned) { const boundary = 1n << BigInt(size - 1); return [0n - boundary, boundary - 1n]; } + +/** + * Asserts that a given part fits within the specified slice size. + * @param {bigint | number | string} part - The part to check. + * @param {number} sliceSize - The size of the slice in bits (e.g., 32, 64, 128) + * @returns {void} + * @throws {RangeError} If the part does not fit within the slice size. + */ +function assertSliceFits(part, sliceSize) { + const fitsSigned = BigInt.asIntN(sliceSize, part) === part; + const fitsUnsigned = BigInt.asUintN(sliceSize, part) === part; + + if (!fitsSigned && !fitsUnsigned) { + throw new RangeError( + `slice value ${part} does not fit in ${sliceSize} bits` + ); + } +} diff --git a/src/large-int.js b/src/large-int.js index 78f0835..5a24313 100644 --- a/src/large-int.js +++ b/src/large-int.js @@ -58,13 +58,21 @@ export class LargeInt extends XdrPrimitiveType { * @inheritDoc */ static read(reader) { - const { size } = this.prototype; - if (size === 64) return new this(reader.readBigUInt64BE()); - return new this( - ...Array.from({ length: size / 64 }, () => - reader.readBigUInt64BE() - ).reverse() - ); + const { size, unsigned } = this.prototype; + if (size === 64) { + return new this( + unsigned ? reader.readBigUInt64BE() : reader.readBigInt64BE() + ); + } + // assemble bigint directly from big-endian 64-bit chunks + let value = 0n; + for (let i = size / 64 - 1; i >= 0; i--) { + value |= reader.readBigUInt64BE() << BigInt(i * 64); + } + if (!unsigned) { + value = BigInt.asIntN(size, value); + } + return new this(value); } /** @@ -88,12 +96,12 @@ export class LargeInt extends XdrPrimitiveType { writer.writeBigInt64BE(value); } } else { - for (const part of sliceBigInt(value, size, 64).reverse()) { - if (unsigned) { - writer.writeBigUInt64BE(part); - } else { - writer.writeBigInt64BE(part); - } + // extract 64-bit chunks directly from bigint, big-endian order + const uvalue = unsigned ? value : BigInt.asUintN(size, value); + for (let i = size / 64 - 1; i >= 0; i--) { + writer.writeBigUInt64BE( + (uvalue >> BigInt(i * 64)) & 0xffffffffffffffffn + ); } } } @@ -102,7 +110,11 @@ export class LargeInt extends XdrPrimitiveType { * @inheritDoc */ static isValid(value) { - return typeof value === 'bigint' || value instanceof this; + if (value instanceof this) return true; + if (typeof value === 'bigint') { + return value >= this.MIN_VALUE && value <= this.MAX_VALUE; + } + return false; } /** diff --git a/test/unit/bigint-encoder_test.js b/test/unit/bigint-encoder_test.js index 1424c84..e2f978d 100644 --- a/test/unit/bigint-encoder_test.js +++ b/test/unit/bigint-encoder_test.js @@ -250,6 +250,84 @@ describe('encodeBigIntWithPrecision', function () { } } }); + + it(`throws on slice overflow and underflow`, () => { + const invalidCases = [ + { + parts: [0n, 0x100000000n], + bits: 64, + unsigned: true, + reason: 'u32 slice overflow (+2^32)' + }, + { + parts: [0n, -0x80000001n], + bits: 64, + unsigned: false, + reason: 'i32 slice underflow (< -2^31)' + }, + { + parts: [0n, 0n, 0n, 0x10000000000000000n], + bits: 256, + unsigned: true, + reason: 'u64 slice overflow (+2^64)' + }, + { + parts: [0n, 0n, 0n, -0x8000000000000001n], + bits: 256, + unsigned: false, + reason: 'i64 slice underflow (< -2^63)' + } + ]; + + for (const { parts, bits, unsigned, reason } of invalidCases) { + expect( + () => encodeBigIntFromBits(parts, bits, unsigned), + `${formatIntName(bits, unsigned)} should throw for ${reason}` + ).to.throw(RangeError, /does not fit/i); + } + }); + + it(`accepts exact slice boundary values`, () => { + const validCases = [ + { + parts: [0n, 0xffffffffn], + bits: 64, + unsigned: true, + expected: 0xffffffff00000000n, + reason: 'u32 upper boundary (2^32 - 1)' + }, + { + parts: [0n, -0x80000000n], + bits: 64, + unsigned: false, + expected: -0x8000000000000000n, + reason: 'i32 lower boundary (-2^31)' + }, + { + parts: [0n, 0n, 0n, 0xffffffffffffffffn], + bits: 256, + unsigned: true, + expected: + 0xffffffffffffffff000000000000000000000000000000000000000000000000n, + reason: 'u64 upper boundary (2^64 - 1)' + }, + { + parts: [0n, 0n, 0n, -0x8000000000000000n], + bits: 256, + unsigned: false, + expected: + -0x8000000000000000000000000000000000000000000000000000000000000000n, + reason: 'i64 lower boundary (-2^63)' + } + ]; + + for (const { parts, bits, unsigned, expected, reason } of validCases) { + expect( + encodeBigIntFromBits(parts, bits, unsigned), + `${formatIntName(bits, unsigned)} should accept ${reason}` + ).to.eq(expected); + } + }); }); describe('sliceBigInt', function () { diff --git a/test/unit/hyper_test.js b/test/unit/hyper_test.js index 2d5acd4..587c665 100644 --- a/test/unit/hyper_test.js +++ b/test/unit/hyper_test.js @@ -84,3 +84,25 @@ describe('Hyper.fromString', function () { expect(() => Hyper.fromString('105946095601.5')).to.throw(/bigint/); }); }); + +describe('Hyper overflow/underflow', function () { + it('throws when constructing with a value exceeding i64 max', function () { + const tooBig = 2n ** 63n; // one above MAX_VALUE + expect(() => new Hyper(tooBig)).to.throw(/out of range|does not fit/i); + }); + + it('throws when constructing with a value below i64 min', function () { + const tooSmall = -(2n ** 63n) - 1n; // one below MIN_VALUE + expect(() => new Hyper(tooSmall)).to.throw(/out of range|does not fit/i); + }); + + it('throws for a 300-bit bigint', function () { + const huge = 2n ** 300n; + expect(() => new Hyper(huge)).to.throw(/out of range|does not fit/i); + }); + + it('accepts exact boundary values without throwing', function () { + expect(() => new Hyper(Hyper.MAX_VALUE)).to.not.throw(); + expect(() => new Hyper(Hyper.MIN_VALUE)).to.not.throw(); + }); +}); diff --git a/test/unit/large-int-128_test.js b/test/unit/large-int-128_test.js new file mode 100644 index 0000000..cc4af9e --- /dev/null +++ b/test/unit/large-int-128_test.js @@ -0,0 +1,216 @@ +import { XdrWriter } from '../../src/serialization/xdr-writer'; +import { XdrReader } from '../../src/serialization/xdr-reader'; +import { LargeInt } from '../../src/large-int'; + +// -- Inline 128-bit subclasses for testing the >64 bit LargeInt paths -- + +class Int128 extends LargeInt { + constructor(...args) { + super(args); + } + get size() { + return 128; + } + get unsigned() { + return false; + } +} +Int128.defineIntBoundaries(); + +class UnsignedInt128 extends LargeInt { + constructor(...args) { + super(args); + } + get size() { + return 128; + } + get unsigned() { + return true; + } +} +UnsignedInt128.defineIntBoundaries(); + +// ---------- Construction ---------- + +describe('Int128 construction', function () { + it('constructs zero', function () { + const val = new Int128(0n); + expect(val.toBigInt()).to.eql(0n); + }); + + it('constructs positive values', function () { + const val = new Int128(123456789012345678901234n); + expect(val.toBigInt()).to.eql(123456789012345678901234n); + }); + + it('constructs negative values', function () { + const val = new Int128(-42n); + expect(val.toBigInt()).to.eql(-42n); + }); + + it('constructs from exact boundary values', function () { + expect(() => new Int128(Int128.MAX_VALUE)).to.not.throw(); + expect(() => new Int128(Int128.MIN_VALUE)).to.not.throw(); + expect(new Int128(Int128.MAX_VALUE).toBigInt()).to.eql(Int128.MAX_VALUE); + expect(new Int128(Int128.MIN_VALUE).toBigInt()).to.eql(Int128.MIN_VALUE); + }); + + it('constructs from two 64-bit chunks', function () { + // low=1, high=2 => 2 * 2^64 + 1 + const val = new Int128(1n, 2n); + expect(val.toBigInt()).to.eql(2n * 2n ** 64n + 1n); + }); + + it('constructs from four 32-bit chunks', function () { + const val = new Int128(1n, 2n, 3n, 4n); + expect(val.toBigInt()).to.eql((4n << 96n) | (3n << 64n) | (2n << 32n) | 1n); + }); +}); + +describe('UnsignedInt128 construction', function () { + it('constructs zero', function () { + const val = new UnsignedInt128(0n); + expect(val.toBigInt()).to.eql(0n); + }); + + it('constructs max value', function () { + const val = new UnsignedInt128(UnsignedInt128.MAX_VALUE); + expect(val.toBigInt()).to.eql(2n ** 128n - 1n); + }); +}); + +// ---------- Overflow / Underflow ---------- + +describe('Int128 overflow/underflow', function () { + it('throws when value exceeds i128 max', function () { + const tooBig = 2n ** 127n; // one above MAX_VALUE + expect(() => new Int128(tooBig)).to.throw(/out of range|does not fit/i); + }); + + it('throws when value is below i128 min', function () { + const tooSmall = -(2n ** 127n) - 1n; + expect(() => new Int128(tooSmall)).to.throw(/out of range|does not fit/i); + }); + + it('throws for a 300-bit bigint', function () { + expect(() => new Int128(2n ** 300n)).to.throw(/out of range|does not fit/i); + }); +}); + +describe('UnsignedInt128 overflow/underflow', function () { + it('throws when value exceeds u128 max', function () { + const tooBig = 2n ** 128n; + expect(() => new UnsignedInt128(tooBig)).to.throw( + /out of range|does not fit/i + ); + }); + + it('throws for negative values', function () { + expect(() => new UnsignedInt128(-1n)).to.throw(/positive/i); + }); +}); + +// ---------- Read / Write round-trip ---------- + +describe('Int128 read/write', function () { + it('round-trips zero', function () { + const original = new Int128(0n); + const writer = new XdrWriter(16); + Int128.write(original, writer); + const reader = new XdrReader(writer.finalize()); + const decoded = Int128.read(reader); + expect(decoded.toBigInt()).to.eql(0n); + }); + + it('round-trips positive values', function () { + const value = 123456789012345678901234n; + const original = new Int128(value); + const writer = new XdrWriter(16); + Int128.write(original, writer); + const reader = new XdrReader(writer.finalize()); + const decoded = Int128.read(reader); + expect(decoded.toBigInt()).to.eql(value); + }); + + it('round-trips negative values', function () { + const value = -987654321098765432109876n; + const original = new Int128(value); + const writer = new XdrWriter(16); + Int128.write(original, writer); + const reader = new XdrReader(writer.finalize()); + const decoded = Int128.read(reader); + expect(decoded.toBigInt()).to.eql(value); + }); + + it('round-trips max value', function () { + const writer = new XdrWriter(16); + Int128.write(new Int128(Int128.MAX_VALUE), writer); + const reader = new XdrReader(writer.finalize()); + expect(Int128.read(reader).toBigInt()).to.eql(Int128.MAX_VALUE); + }); + + it('round-trips min value', function () { + const writer = new XdrWriter(16); + Int128.write(new Int128(Int128.MIN_VALUE), writer); + const reader = new XdrReader(writer.finalize()); + expect(Int128.read(reader).toBigInt()).to.eql(Int128.MIN_VALUE); + }); +}); + +describe('UnsignedInt128 read/write', function () { + it('round-trips zero', function () { + const writer = new XdrWriter(16); + UnsignedInt128.write(new UnsignedInt128(0n), writer); + const reader = new XdrReader(writer.finalize()); + expect(UnsignedInt128.read(reader).toBigInt()).to.eql(0n); + }); + + it('round-trips max value', function () { + const writer = new XdrWriter(16); + UnsignedInt128.write(new UnsignedInt128(UnsignedInt128.MAX_VALUE), writer); + const reader = new XdrReader(writer.finalize()); + expect(UnsignedInt128.read(reader).toBigInt()).to.eql( + UnsignedInt128.MAX_VALUE + ); + }); +}); + +// ---------- isValid ---------- + +describe('Int128.isValid', function () { + it('returns true for in-range bigint values', function () { + expect(Int128.isValid(0n)).to.be.true; + expect(Int128.isValid(-1n)).to.be.true; + expect(Int128.isValid(Int128.MAX_VALUE)).to.be.true; + expect(Int128.isValid(Int128.MIN_VALUE)).to.be.true; + }); + + it('returns false for out-of-range bigint values', function () { + expect(Int128.isValid(2n ** 127n)).to.be.false; + expect(Int128.isValid(-(2n ** 127n) - 1n)).to.be.false; + }); + + it('returns true for instances', function () { + expect(Int128.isValid(new Int128(42n))).to.be.true; + }); + + it('returns false for non-bigint/non-instance values', function () { + expect(Int128.isValid(42)).to.be.false; + expect(Int128.isValid('42')).to.be.false; + expect(Int128.isValid(null)).to.be.false; + }); +}); + +// ---------- toString / toJSON ---------- + +describe('Int128 serialization helpers', function () { + it('toString returns decimal string', function () { + const val = new Int128(-42n); + expect(val.toString()).to.eql('-42'); + }); + + it('toJSON returns object with string value', function () { + const val = new Int128(999n); + expect(val.toJSON()).to.eql({ _value: '999' }); + }); +}); diff --git a/test/unit/unsigned-hyper_test.js b/test/unit/unsigned-hyper_test.js index 6a1f773..8100f57 100644 --- a/test/unit/unsigned-hyper_test.js +++ b/test/unit/unsigned-hyper_test.js @@ -72,3 +72,28 @@ describe('UnsignedHyper.fromString', function () { expect(() => UnsignedHyper.fromString('105946095601.5')).to.throw(/bigint/); }); }); + +describe('UnsignedHyper overflow', function () { + it('throws when constructing with a value exceeding u64 max', function () { + const tooBig = 2n ** 64n; // one above MAX_VALUE + expect(() => new UnsignedHyper(tooBig)).to.throw( + /out of range|does not fit/i + ); + }); + + it('throws for a 300-bit bigint', function () { + const huge = 2n ** 300n; + expect(() => new UnsignedHyper(huge)).to.throw( + /out of range|does not fit/i + ); + }); + + it('throws for negative values', function () { + expect(() => new UnsignedHyper(-1n)).to.throw(/positive/); + }); + + it('accepts exact boundary values without throwing', function () { + expect(() => new UnsignedHyper(UnsignedHyper.MAX_VALUE)).to.not.throw(); + expect(() => new UnsignedHyper(UnsignedHyper.MIN_VALUE)).to.not.throw(); + }); +});