From 2b6bcb702a1f73092686e72bfaa4d42be7875efa Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Tue, 14 Jul 2026 10:19:41 -0700 Subject: [PATCH] Add opt-in BigInt support, throw when parsing out of bounds values --- CHANGELOG.md | 6 ++++ README.md | 34 ++++++++++++++---- index.d.ts | 12 +++++++ lib/compiler.js | 33 +++++++++++++++++- lib/parser.js | 8 ++--- src/toml.pegjs | 10 +++--- test/spec-test.js | 15 ++++++-- test/test_toml.js | 88 +++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 187 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e77596..e40d2b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +5.0.0 - Unreleased +===================== + +* **Breaking:** Integers outside JavaScript's safe range (beyond ±`Number.MAX_SAFE_INTEGER`) now throw a parse error instead of silently returning a rounded value ([#28](https://github.com/BinaryMuse/toml-node/issues/28)). Opt in to lossless handling of the full 64-bit range with `toml.parse(input, { bigint: true })`, which returns all integer values as `BigInt`. +* **Breaking:** Integers outside TOML's 64-bit signed integer range now throw a parse error in either mode, as required by the spec. Previously they were silently rounded. + 4.3.0 - Unreleased ===================== diff --git a/README.md b/README.md index 7d87edd..bcf7984 100644 --- a/README.md +++ b/README.md @@ -8,18 +8,17 @@ If you haven't heard of TOML, well you're just missing out. [Go check it out now TOML Spec Support ----------------- -toml-node supports [TOML v1.1.0](https://toml.io/en/v1.1.0), scoring **673/680 (99.0%)** on the official [toml-test](https://github.com/toml-lang/toml-test) compliance suite: +toml-node supports [TOML v1.1.0](https://toml.io/en/v1.1.0), scoring **702/708 (99.2%)** on the official [toml-test](https://github.com/toml-lang/toml-test) compliance suite: | | Pass | Total | Rate | |---|---|---|---| -| Valid tests | 213 | 214 | 99.5% | -| Invalid tests | 460 | 466 | 98.7% | -| **Total** | **673** | **680** | **99.0%** | +| Valid tests | 218 | 218 | 100% | +| Invalid tests | 484 | 490 | 98.8% | +| **Total** | **702** | **708** | **99.2%** | -The 7 remaining failures are inherent JavaScript platform limitations shared by all JS TOML parsers: +The 6 remaining failures are inherent JavaScript platform limitations shared by all JS TOML parsers: they cover UTF-8 encoding validation, which Node.js handles at the engine level before the parser sees the data. -- 1 valid test: 64-bit integer precision (`Number` can't represent values beyond `Number.MAX_SAFE_INTEGER`) -- 6 invalid tests: UTF-8 encoding validation (Node.js handles UTF-8 decoding at the engine level before the parser sees the data) +Note that integers beyond `Number.MAX_SAFE_INTEGER` require the [`bigint` option](#integer-range-and-bigint) to parse losslessly; without it they throw a parse error rather than silently losing precision. ### Feature Support @@ -68,6 +67,27 @@ To guard against stack overflow on maliciously deep input, arrays and inline tab toml.parse(someTomlString, { maxDepth: 100 }); ``` +### Integer Range and BigInt + +TOML requires parsers to handle the full range of 64-bit signed integers, but JavaScript's `number` type can only represent integers up to `Number.MAX_SAFE_INTEGER` (2⁵³ − 1) losslessly. By default, `toml.parse` returns integers as `number` and throws a parse error when a value falls outside the safe range, rather than silently returning a rounded value: + +```javascript +toml.parse('id = 771752188537605140'); +// Error: Integer 771752188537605140 cannot be represented losslessly +// as a JavaScript number. Use the `bigint` option to parse integers +// as BigInt values. +``` + +Pass `bigint: true` to instead parse **all** integers as `BigInt`, preserving the full 64-bit range: + +```javascript +const data = toml.parse('id = 771752188537605140\ncount = 3', { bigint: true }); +data.id // 771752188537605140n +data.count // 3n +``` + +Integers outside the 64-bit signed range always throw, in either mode, as required by the spec. Floats are unaffected by all of this: TOML floats are IEEE 754 binary64 values, which is exactly what a JavaScript `number` is, so every TOML float is represented as faithfully as the spec intends. + ### Date/Time Values Offset date-times are returned as JavaScript `Date` objects. Local date-times, local dates, and local times are returned as strings since they have no timezone information and can't be losslessly represented as `Date`: diff --git a/index.d.ts b/index.d.ts index 357cb31..cd097b7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -19,6 +19,18 @@ declare module 'toml' { */ maxDepth?: number; + /** + * When true, all integer values are returned as `BigInt` instead of + * `number`, preserving the full 64-bit signed integer range required by + * TOML. When false (the default), integers outside + * `Number.MIN_SAFE_INTEGER`..`Number.MAX_SAFE_INTEGER` throw a parse + * error rather than silently losing precision. + * + * Floats are unaffected: TOML floats are IEEE 754 binary64, which is + * exactly what a JavaScript number is. + */ + bigint?: boolean; + /** * When true, date/time values are returned as Temporal objects instead of * the default representations: diff --git a/lib/compiler.js b/lib/compiler.js index 60afd6f..305c042 100644 --- a/lib/compiler.js +++ b/lib/compiler.js @@ -1,4 +1,10 @@ "use strict"; + +var INT64_MIN = -(2n ** 63n); +var INT64_MAX = 2n ** 63n - 1n; +var MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER); +var MIN_SAFE = BigInt(Number.MIN_SAFE_INTEGER); + function compile(nodes, inputText, options) { options = options || {}; var temporal = null; @@ -102,7 +108,9 @@ function compile(nodes, inputText, options) { function reduceValueNode(node) { - if (node.type === "Array") { + if (node.type === "Integer") { + return reduceInteger(node); + } else if (node.type === "Array") { return reduceArray(node.value); } else if (node.type === "InlineTable") { return reduceInlineTableNode(node.value); @@ -130,6 +138,29 @@ function compile(nodes, inputText, options) { return str.replace(/\.(\d{9})\d+/, ".$1"); } + // Integer nodes arrive from the parser as BigInt so the full 64-bit + // range is preserved exactly. + function reduceInteger(node) { + var value = node.value; + if (value < INT64_MIN || value > INT64_MAX) { + genError( + "Integer " + value + " is outside the 64-bit signed integer range required by TOML.", + node.offset + ); + } + if (options.bigint) { + return value; + } + if (value < MIN_SAFE || value > MAX_SAFE) { + genError( + "Integer " + value + " cannot be represented losslessly as a JavaScript number. " + + "Use the `bigint` option to parse integers as BigInt values.", + node.offset + ); + } + return Number(value); + } + function reduceInlineTableNode(values) { var obj = createTable(); var definedKeys = new Set(); diff --git a/lib/parser.js b/lib/parser.js index ef33858..71e6b1e 100644 --- a/lib/parser.js +++ b/lib/parser.js @@ -323,10 +323,10 @@ function peg$parse(input, options) { function peg$f39(sign, digits) { return (sign === '-' ? '-' : '') + digits } function peg$f40() { return '0' } function peg$f41(sign, digits) { return (sign || '') + digits } - function peg$f42(digits) { return node('Integer', parseInt(stripUnderscores(digits), 16), offset()) } - function peg$f43(digits) { return node('Integer', parseInt(stripUnderscores(digits), 8), offset()) } - function peg$f44(digits) { return node('Integer', parseInt(stripUnderscores(digits), 2), offset()) } - function peg$f45(text) { return node('Integer', parseInt(stripUnderscores(text), 10), offset()) } + function peg$f42(digits) { return node('Integer', BigInt('0x' + stripUnderscores(digits)), offset()) } + function peg$f43(digits) { return node('Integer', BigInt('0o' + stripUnderscores(digits)), offset()) } + function peg$f44(digits) { return node('Integer', BigInt('0b' + stripUnderscores(digits)), offset()) } + function peg$f45(text) { return node('Integer', BigInt(stripUnderscores(text)), offset()) } function peg$f46(sign) { return (sign || '') + '0' } function peg$f47(sign, digits) { return (sign || '') + digits } function peg$f48() { return node('Boolean', true, offset()) } diff --git a/src/toml.pegjs b/src/toml.pegjs index 9ad7da1..7ac9f5d 100644 --- a/src/toml.pegjs +++ b/src/toml.pegjs @@ -254,11 +254,13 @@ FLOAT_DEC_INT float_exp_text = sign:[+-]? digits:$DEC_DIGIT_SEQ { return (sign || '') + digits } +// Integer values are BigInt so the full 64-bit range survives parsing; +// the compiler converts to Number (or errors) based on options. integer - = '0x' digits:$HEX_DIGIT_SEQ { return node('Integer', parseInt(stripUnderscores(digits), 16), offset()) } - / '0o' digits:$OCT_DIGIT_SEQ { return node('Integer', parseInt(stripUnderscores(digits), 8), offset()) } - / '0b' digits:$BIN_DIGIT_SEQ { return node('Integer', parseInt(stripUnderscores(digits), 2), offset()) } - / text:dec_integer_text { return node('Integer', parseInt(stripUnderscores(text), 10), offset()) } + = '0x' digits:$HEX_DIGIT_SEQ { return node('Integer', BigInt('0x' + stripUnderscores(digits)), offset()) } + / '0o' digits:$OCT_DIGIT_SEQ { return node('Integer', BigInt('0o' + stripUnderscores(digits)), offset()) } + / '0b' digits:$BIN_DIGIT_SEQ { return node('Integer', BigInt('0b' + stripUnderscores(digits)), offset()) } + / text:dec_integer_text { return node('Integer', BigInt(stripUnderscores(text)), offset()) } dec_integer_text = sign:[+-]? '0' !([0-9_]) !'.' { return (sign || '') + '0' } diff --git a/test/spec-test.js b/test/spec-test.js index a948c64..d5c5943 100644 --- a/test/spec-test.js +++ b/test/spec-test.js @@ -28,8 +28,6 @@ var FILES_LIST = path.join(TESTS_DIR, "files-toml-1.1.0"); // Known failures due to JS platform limitations, not parser bugs. // These are excluded from the pass/fail exit code. var KNOWN_FAILURES = [ - // Number can't represent 64-bit integers beyond Number.MAX_SAFE_INTEGER - "valid/integer/long", // Node.js handles UTF-8 decoding at the engine level; invalid byte sequences // are replaced before the parser sees the data, so we can't reject them. "invalid/encoding/bad-codepoint", @@ -40,6 +38,12 @@ var KNOWN_FAILURES = [ "invalid/encoding/bad-utf8-in-string-literal", ]; +// Tests whose integers exceed Number.MAX_SAFE_INTEGER; the parser rejects +// them by default (a documented spec deviation), so run them in bigint mode. +var BIGINT_TESTS = [ + "valid/integer/long", +]; + // --------------------------------------------------------------------------- // Tagged JSON conversion // @@ -137,6 +141,10 @@ function toTaggedJSON(value, typeMap, currentPath) { return { type: "bool", value: String(value) }; } + if (typeof value === "bigint") { + return { type: "integer", value: String(value) }; + } + if (typeof value === "number") { if (Number.isNaN(value)) { return { type: "float", value: "nan" }; @@ -342,7 +350,8 @@ function runValidTest(testPath) { try { var astNodes = parser.parse(tomlContent); var typeMap = buildTypeMap(astNodes); - var parsed = toml.parse(tomlContent); + var options = BIGINT_TESTS.indexOf(testPath) !== -1 ? { bigint: true } : undefined; + var parsed = toml.parse(tomlContent, options); var tagged = toTaggedJSON(parsed, typeMap, ""); var diff = deepEqual(tagged, expectedJSON); if (diff) { diff --git a/test/test_toml.js b/test/test_toml.js index 799f782..6e8c252 100644 --- a/test/test_toml.js +++ b/test/test_toml.js @@ -286,6 +286,94 @@ describe("numbers", function () { }); }); +describe("integer range", function () { + it("parses integers at the edge of the safe range", function () { + parsesToml("a = 9007199254740991\nb = -9007199254740991", { + a: Number.MAX_SAFE_INTEGER, + b: Number.MIN_SAFE_INTEGER, + }); + }); + + it("rejects integers beyond the safe range by default", function () { + assert.throws(function () { + toml.parse("a = 9007199254740993"); + }, /cannot be represented losslessly.*bigint/s); + assert.throws(function () { + toml.parse("a = -9007199254740993"); + }, /cannot be represented losslessly.*bigint/s); + }); + + it("reports the position of out-of-range integers", function () { + try { + toml.parse("a = 1\nb = 2\nc = 9223372036854775807"); + assert.fail("expected parse error"); + } catch (e) { + assert.strictEqual(e.line, 3); + assert.strictEqual(e.column, 5); + } + }); + + it("rejects integers outside the int64 range in both modes", function () { + assert.throws(function () { + toml.parse("a = 9223372036854775808"); + }, /64-bit signed integer range/); + assert.throws(function () { + toml.parse("a = 9223372036854775808", { bigint: true }); + }, /64-bit signed integer range/); + assert.throws(function () { + toml.parse("a = -9223372036854775809", { bigint: true }); + }, /64-bit signed integer range/); + assert.throws(function () { + toml.parse("a = 0xffff_ffff_ffff_ffff", { bigint: true }); + }, /64-bit signed integer range/); + }); + + it("large-magnitude floats are unaffected", function () { + parsesToml("a = 9007199254740993.0\nb = 1e300", { + a: 9007199254740992, + b: 1e300, + }); + }); +}); + +describe("bigint mode", function () { + it("returns all integers as BigInt", function () { + var result = toml.parse("a = 42\nb = -17\nc = 0x10\nd = 0o755\ne = 0b101", { + bigint: true, + }); + assert.deepStrictEqual(normalize(result), { + a: 42n, + b: -17n, + c: 16n, + d: 493n, + e: 5n, + }); + }); + + it("preserves the full int64 range exactly", function () { + var result = toml.parse( + "max = 9223372036854775807\nmin = -9223372036854775808", + { bigint: true } + ); + assert.strictEqual(result.max, 9223372036854775807n); + assert.strictEqual(result.min, -9223372036854775808n); + }); + + it("converts integers inside arrays and inline tables", function () { + var result = toml.parse("a = [1, 2]\nb = { c = 9223372036854775807 }", { + bigint: true, + }); + assert.deepStrictEqual(normalize(result.a), [1n, 2n]); + assert.strictEqual(result.b.c, 9223372036854775807n); + }); + + it("leaves floats as numbers", function () { + var result = toml.parse("a = 3.0\nb = 5e22", { bigint: true }); + assert.strictEqual(result.a, 3.0); + assert.strictEqual(result.b, 5e22); + }); +}); + describe("whitespace", function () { it("handles whitespace", function () { parsesToml("a = 1\n \n b = 2 ", { a: 1, b: 2 });