From 42b6ba1ee80f0b401d4901941adfba833e8d167d Mon Sep 17 00:00:00 2001 From: Andris Reinman Date: Wed, 7 Oct 2026 12:39:52 +0300 Subject: [PATCH] feat(parser): add a number64 option for literal sizes and partial ranges RFC 9051 section 9 uses number64 for literal sizes and partial ranges. With the option they are accepted up to Number.MAX_SAFE_INTEGER. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + lib/parser.js | 14 +++++++++++--- test/parser.js | 20 ++++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 51385fa..9f7997c 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Options - **multiWords** (Array) commands that are joined with the next word, default value is `["UID", "AUTHENTICATE"]` - **literalPlus** (Boolean) accept non-synchronizing literals `{n+}` (RFC 7888), and `~{n+}` when `literal8` is set as well (RFC 4466) - **literal8** (Boolean) accept `~{n}` literals (RFC 3516, RFC 9051), returned as `{type: "LITERAL8", value}` nodes. Unlike a `LITERAL`, the value may contain NUL +- **number64** (Boolean) accept literal sizes and partial ranges (``) up to `Number.MAX_SAFE_INTEGER` as the RFC 9051 number64 rule allows. Without it both are limited to 32 bits (RFC 3501 number) - **utf8** (Boolean) accept valid UTF-8 in quoted strings (RFC 9051 and RFC 9755 UTF8=ACCEPT). The value is still returned as a binary string (one char per octet). Invalid UTF-8 (overlong forms, surrogates, truncated sequences, values above U+10FFFF) still throws The function returns an object in the following form: diff --git a/lib/parser.js b/lib/parser.js index 76a6ad7..418efbb 100644 --- a/lib/parser.js +++ b/lib/parser.js @@ -6,6 +6,10 @@ const formalSyntax = require('./formal'); // RFC 3501 9: number is an unsigned 32-bit integer const MAX_NUMBER = 0xffffffff; +// RFC 9051 9: number64 is a 63-bit integer for literal sizes and partial ranges. Larger values than +// Number.MAX_SAFE_INTEGER can not be represented exactly, so with the number64 option that is the limit +const MAX_NUMBER64 = Number.MAX_SAFE_INTEGER; + // Deeper input (lists, sections and the values in them) is refused instead of risking the stack const MAX_NODE_DEPTH = 25; @@ -128,6 +132,9 @@ function TokenParser(startPos, str, options) { // the longest value that may open a section, longer atoms are not compared at all this.maxSectionName = Math.max(0, ...this.options.allowSection.map(name => name.length)); + // literal sizes and partial ranges are number64 values in IMAP4rev2 (RFC 9051 section 9) + this.maxNumber = this.options.number64 ? MAX_NUMBER64 : MAX_NUMBER; + this.tree = this.currentNode = this.createNode(); this.currentNode.type = 'TREE'; @@ -471,7 +478,7 @@ TokenParser.prototype.readLiteral = function (start, isLiteral8) { throw this.unexpected(i); } - if (size > MAX_NUMBER || i + size > len) { + if (size > this.maxNumber || i + size > len) { throw parserError('Unexpected end of input', this.pos + len); } @@ -489,7 +496,8 @@ TokenParser.prototype.readLiteral = function (start, isLiteral8) { return this.afterValue(i + size); }; -// RFC 3501 9: partial = "<" number "." nz-number ">", a single number is also accepted +// RFC 3501 9: partial = "<" number "." nz-number ">", a single number is also accepted. RFC 9051 9 uses +// number64 and nz-number64, allowed with the number64 option TokenParser.prototype.readPartial = function (start) { const str = this.str; const len = str.length; @@ -506,7 +514,7 @@ TokenParser.prototype.readPartial = function (start) { if (nonZero && str.charAt(numStart) === '0') { throw parserError('Invalid partial', this.pos + numStart); } - if (Number(str.slice(numStart, i)) > MAX_NUMBER) { + if (Number(str.slice(numStart, i)) > this.maxNumber) { throw parserError('Invalid partial', this.pos + numStart); } }; diff --git a/test/parser.js b/test/parser.js index cbfa04a..83b3f0b 100644 --- a/test/parser.js +++ b/test/parser.js @@ -607,6 +607,26 @@ describe('Literals', () => { }); }); +describe('number64 option', () => { + // RFC 9051 section 9: partial-range = number64 ["." nz-number64], literal = "{" number64 ["+"] "}" + it('accepts partial ranges above 32 bits', () => { + assert.deepEqual(parser('A1 FETCH 1 BODY[]<4294967296.4294967297>', { number64: true }).attributes[1].partial, [4294967296, 4294967297]); + assert.deepEqual(parser('A1 FETCH 1 BODY[]<9007199254740991>', { number64: true }).attributes[1].partial, [9007199254740991]); + assert.throws(() => parser('A1 FETCH 1 BODY[]<4294967296.1>')); + }); + + it('refuses values that can not be represented exactly', () => { + assert.throws(() => parser('A1 FETCH 1 BODY[]<9007199254740992>', { number64: true }), /Invalid partial/); + assert.throws(() => parser('A1 FETCH 1 BODY[]<0.0>', { number64: true }), /Invalid partial/); + }); + + it('checks literal sizes against the 64-bit limit', () => { + // the size is valid, there is just not enough input + assert.throws(() => parser('A1 CMD {4294967296}\r\nabc', { number64: true }), /Unexpected end of input/); + assert.deepEqual(parser('A1 CMD {3}\r\nabc', { number64: true }).attributes, [{ type: 'LITERAL', value: 'abc' }]); + }); +}); + describe('Partials', () => { it('validates the partial range', () => { assert.deepEqual(parser('A1 FETCH 1 BODY[]<5>').attributes[1].partial, [5]);