Skip to content

Fix MathUtil conversion regressions on non-validation paths - #97

Merged
pjfanning merged 2 commits into
apache:trunkfrom
pjfanning:fix-mathutil-conversion-regressions
Aug 26, 2026
Merged

Fix MathUtil conversion regressions on non-validation paths#97
pjfanning merged 2 commits into
apache:trunkfrom
pjfanning:fix-mathutil-conversion-regressions

Conversation

@pjfanning

@pjfanning pjfanning commented Aug 26, 2026

Copy link
Copy Markdown
Member

Follow-up to #96. That PR fixed one instance of a pattern that recurs across the 5.4.0 MathUtil work: a silently-truncating BigDecimal conversion was replaced by one that throws, on a path that isn't a validation path and has no way to report a failure.

Four more call sites in that family. All are reachable through the public API and verified against a build of trunk.

1. hashCode() throws — JavaDecimalHolder.value_hash_code()

XmlDecimal(2000-digit).valueHashCode() -> IllegalArgumentException: BigDecimal magnitude too large ... (limit 1024)
XmlDecimal(1E+2000).valueHashCode()    -> IllegalArgumentException: ...

XmlObjectBase.hashCode() calls this, so an oversized decimal poisons every HashMap/HashSet it goes into. Such values are legitimately reachable — set_BigDecimal is unguarded, so setBigDecimalValue(...) stores them and getBigDecimalValue() returns them; only hashing fails.

The max-number-chars limit can't be applied here anyway. JavaIntegerHolder.value_hash_code has no limit (it already holds the BigInteger), an XmlInteger holding the same value is valueEquals to the decimal, and the method's contract is that they hash alike — capping one side breaks that. The limit also bounds parse-time work; by hash time the value is already in memory, so it no longer caps anything an attacker controls.

What does amplify at hash time is a large negative scale, where a 13-character BigDecimal expands to a two-billion-digit BigInteger. This guards that directly, and falls back to a hash of the canonical form. The branch is taken on integer-digit count (precision() - scale()), which is representation-invariant, so 1E+200000 and the same number written out in full still hash alike.

2. + 3. Out-of-range reported inconsistently

The two magnitude regimes diverged:

XmlInt.setBigDecimalValue(1E+20)      -> XmlValueOutOfRangeException
XmlInt.setBigDecimalValue(2000-digit) -> IllegalArgumentException     (leaked from MathUtil)

Same for XmlLong, XmlInteger, and XmlObjectBase.getBigIntegerValue(). The lexical path already reports XmlValueOutOfRangeException for these (see MaxNumberOfCharsTest); only the programmatic setBigDecimalValue/getBigIntegerValue path leaked the raw type. Translated once in XmlObjectBase.to_BigInteger(). Since XmlValueOutOfRangeException extends IllegalArgumentException, existing assertions are unaffected.

While there, the int and long holders were also applying the wrong bound. maxNumberOfCharsForNumbers limits numbers read out of a document; gating a programmatic setter with it made setBigDecimalValue disagree with the other two setters for the same value:

maxChars(8): XmlLong.setBigDecimalValue(123456789012)  -> rejected, "limit 8"
maxChars(8): XmlLong.setBigIntegerValue(123456789012)  -> ok
maxChars(8): XmlLong.setLongValue(123456789012)        -> ok

The real constraint is the type's own range, which set_BigInteger already enforces, so they now pass the width of that range instead — 19 digits for long, 10 for int, at either end, since precision() ignores the sign. MathUtil still does the conversion, so both expansion traps stay guarded: a large negative scale is rejected before being expanded, and a value below 1 truncates to zero without computing the divisor. xs:integer is unbounded, so it keeps the configured limit — its only bound.

4. GDurationBuilder narrower than GDateBuilderGDurationBuilder.normalize()

Identical whole-second carry computation, but GDurationBuilder used toInt while GDateBuilder._normalizeTime uses toLong — into a long carry in both. setFraction is unvalidated, so:

GDurationBuilder.normalize(), fraction=1E+10 -> IllegalArgumentException: Value can't be converted to int
GDurationBuilder.normalize(), fraction=1E+3  -> ok

GDateBuilder accepts that magnitude. Now both use toLong.

Not addressed here

  • Several call sites still hardcode DEFAULT_MAX_NUMBER_CHARS instead of the configured limit, so XmlOptions.setMaxNumberOfCharsForNumbers doesn't reach them: JavaIntHolderEx:160, JavaLongHolderEx:160, JavaIntegerHolderEx:163, GDate:278, GDuration:136, StscTranslator:1515, SampleXmlUtil:412,442.
  • MathUtil.parseAsInt is the only parse method with no maxNumberOfChars overload (and its 1024-char guard is moot, since Integer.parseInt rejects anything past ~11 chars).
  • An alternative to the fix in 1 would be to apply the limit in JavaDecimalHolder.set_BigDecimal/JavaIntegerHolder.set_BigInteger so an oversized value can never be stored, letting value_hash_code keep the cap. That's a broader change to setBigDecimalValue semantics, so it's left out of this PR.

Tests

Six in MaxNumberOfCharsTest (hash doesn't throw and stays aligned with XmlInteger; hash is scale-independent across the expansion threshold; a huge exponent isn't expanded; the integral setters report XmlValueOutOfRangeException, use their own bound rather than the parse limit, and still reject out-of-range values at both ends) and one in GDateTests for the duration carry.

Full suite: 3082 tests, 0 failures.

🤖 Generated with Claude Code

pjfanning and others added 2 commits August 26, 2026 23:42
5.4.0 replaced several silently-truncating BigDecimal conversions with
MathUtil equivalents that throw. On the parse and validation paths that is
the intent, but four of the call sites are not validation paths:

- JavaDecimalHolder.value_hash_code() applied the max-number-chars limit,
  so hashCode() threw IllegalArgumentException for any decimal over the
  limit, poisoning every HashMap or HashSet holding the object. The limit
  bounds parse-time work and cannot be applied here anyway: the equal
  xsd:integer hashes without a limit, and the hashes have to agree. Guard
  the one case that is genuinely expensive instead - expanding a large
  negative scale, eg 1E+2000000000 - and hash such values from their
  canonical form. The branch is taken on integer-digit count, which is the
  same for every representation of a value, so equal values still agree.

- XmlObjectBase.getBigIntegerValue() and set_BigDecimal() in the int, long
  and integer holders let MathUtil's plain IllegalArgumentException escape,
  where the lexical path for the same value reports it as
  XmlValueOutOfRangeException. Translate it in one place so both paths and
  both magnitude regimes agree.

- GDurationBuilder.normalize() converted the whole-second carry with
  toInt() while GDateBuilder does the same computation with toLong() into
  the same long variable, so a fraction between int and long range threw
  in one and not the other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
set_BigDecimal in the int and long holders applied
maxNumberOfCharsForNumbers, which bounds numbers read out of a document
and has no business gating a value handed to a setter directly: under
maxChars(8), setBigDecimalValue(123456789012) was rejected while
setBigIntegerValue and setLongValue accepted the same value.

The real constraint is the type's own range, which set_BigInteger already
enforces, so pass the width of that range - 19 digits for long, 10 for
int, at either end, as precision() ignores the sign. MathUtil still does
the conversion, so both expansion traps stay guarded: a large negative
scale is rejected before it is expanded, and a value below 1 truncates to
zero without computing the divisor. xs:integer is unbounded and keeps the
configured limit, which is its only bound.

Also drop stripTrailingZeros() from the oversized-value branch of
value_hash_code(): it is quadratic in the number of trailing zeros, so
hashing a 200000-digit value took 39 seconds. The digit count and sign
are just as representation-invariant and are cheap to read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pjfanning
pjfanning merged commit 1ba5513 into apache:trunk Aug 26, 2026
2 checks passed
@pjfanning
pjfanning deleted the fix-mathutil-conversion-regressions branch August 26, 2026 23:08
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.

1 participant