Skip to content

fix: parse full-length numbers instead of truncating digit runs - #72

Merged
dy merged 1 commit into
jkroso:masterfrom
spokodev:fix/number-digit-run-split
Aug 6, 2026
Merged

fix: parse full-length numbers instead of truncating digit runs#72
dy merged 1 commit into
jkroso:masterfrom
spokodev:fix/number-digit-run-split

Conversation

@spokodev

@spokodev spokodev commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

The number regex caps each digit run at {1,16}, so any value with more than 16 digits in a run is split into two matches: the first 16 digits match as one unitless number, and the leftover digits start a fresh match against whatever follows.

import parse from 'parse-duration'

parse('0.30000000000000004s') // => 4000.3   (expected 300.00000000000006)
parse('0.12345678901234567s') // => 7000.12… (expected 123.45678901234567)
parse('12345678901234567ms')  // => 1234567890123463 (expected 12345678901234567)

For '0.30000000000000004s' the fraction 30000000000000004 is 17 digits. The regex matches 0.3000000000000000 (16 fraction digits, i.e. 0.3) with an empty unit, so the compound-parsing loop treats it as bare ms (0.3), and the remaining 4s begins a new match worth 4 * 1000 = 4000. The two sum to 4000.3.

0.30000000000000004 is exactly String(0.1 + 0.2), so this fires on ordinary float-arithmetic results, and 300 is fully representable — the wrong answer is off by more than 13x, not a rounding artefact.

Root cause

The {1,16} bound was added in #61 to guard against ReDoS, replacing the earlier unbounded -?(?:\d+\.?\d*|\d*\.?\d+). The catastrophic backtracking in that pattern came from the two alternatives overlapping (both can match the same digits), not from the run length, so capping the length only masked it while introducing the truncation.

The alternation used here, (?:\d+(?:\.\d+)?|\.\d+), is unambiguous: the first branch requires a leading digit, the second requires a leading dot, so no input can be matched two ways. That removes the backtracking source, which lets the cap go without reintroducing ReDoS.

-const durationRE = /((?:\d{1,16}(?:\.\d{1,16})?|\.\d{1,16})(?:[eE][-+]?\d{1,4})?)\s*([\p{L}]{0,14})/gu
+const durationRE = /((?:\d+(?:\.\d+)?|\.\d+)(?:[eE][-+]?\d+)?)\s*([\p{L}]{0,14})/gu

Verification

  • The three cases above now return the correct values.
  • All existing tests pass; added assertions for long digit runs in test.js.
  • ReDoS check: parsing a 200,000-digit input (and "1" + "0".repeat(100000) + " x") stays well under a millisecond.

The number regex capped each digit run at {1,16}, so a value with more than
16 digits in a run was split: the first 16 digits matched as one (unitless)
number and the remainder started a fresh match. parse('0.30000000000000004s')
returned 4000.3 instead of 300.00000000000006 — the 17th fraction digit '4'
broke off and was reparsed as '4s'. Any decimal with a long fraction (e.g.
String(0.1 + 0.2)) or a >16-digit integer was silently mis-parsed.

The {1,16} bound was introduced to guard against ReDoS, but the catastrophic
backtracking came from the overlapping alternation in the earlier pattern
(\d+\.?\d*|\d*\.?\d+), not from the run length. The alternation here
(\d+(?:\.\d+)?|\.\d+) has no overlapping quantifiers, so it stays linear
without a length cap: a 200k-digit input parses in well under a millisecond.
@dy
dy merged commit 4cbe95b into jkroso:master Aug 6, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants