Fix MathUtil conversion regressions on non-validation paths - #97
Merged
pjfanning merged 2 commits intoAug 26, 2026
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #96. That PR fixed one instance of a pattern that recurs across the 5.4.0
MathUtilwork: a silently-truncatingBigDecimalconversion 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()XmlObjectBase.hashCode()calls this, so an oversized decimal poisons everyHashMap/HashSetit goes into. Such values are legitimately reachable —set_BigDecimalis unguarded, sosetBigDecimalValue(...)stores them andgetBigDecimalValue()returns them; only hashing fails.The max-number-chars limit can't be applied here anyway.
JavaIntegerHolder.value_hash_codehas no limit (it already holds theBigInteger), anXmlIntegerholding the same value isvalueEqualsto 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
BigDecimalexpands to a two-billion-digitBigInteger. 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, so1E+200000and the same number written out in full still hash alike.2. + 3. Out-of-range reported inconsistently
The two magnitude regimes diverged:
Same for
XmlLong,XmlInteger, andXmlObjectBase.getBigIntegerValue(). The lexical path already reportsXmlValueOutOfRangeExceptionfor these (seeMaxNumberOfCharsTest); only the programmaticsetBigDecimalValue/getBigIntegerValuepath leaked the raw type. Translated once inXmlObjectBase.to_BigInteger(). SinceXmlValueOutOfRangeException extends IllegalArgumentException, existing assertions are unaffected.While there, the int and long holders were also applying the wrong bound.
maxNumberOfCharsForNumberslimits numbers read out of a document; gating a programmatic setter with it madesetBigDecimalValuedisagree with the other two setters for the same value:The real constraint is the type's own range, which
set_BigIntegeralready enforces, so they now pass the width of that range instead — 19 digits for long, 10 for int, at either end, sinceprecision()ignores the sign.MathUtilstill 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:integeris unbounded, so it keeps the configured limit — its only bound.4.
GDurationBuildernarrower thanGDateBuilder—GDurationBuilder.normalize()Identical whole-second carry computation, but
GDurationBuilderusedtoIntwhileGDateBuilder._normalizeTimeusestoLong— into along carryin both.setFractionis unvalidated, so:GDateBuilderaccepts that magnitude. Now both usetoLong.Not addressed here
DEFAULT_MAX_NUMBER_CHARSinstead of the configured limit, soXmlOptions.setMaxNumberOfCharsForNumbersdoesn't reach them:JavaIntHolderEx:160,JavaLongHolderEx:160,JavaIntegerHolderEx:163,GDate:278,GDuration:136,StscTranslator:1515,SampleXmlUtil:412,442.MathUtil.parseAsIntis the only parse method with nomaxNumberOfCharsoverload (and its 1024-char guard is moot, sinceInteger.parseIntrejects anything past ~11 chars).JavaDecimalHolder.set_BigDecimal/JavaIntegerHolder.set_BigIntegerso an oversized value can never be stored, lettingvalue_hash_codekeep the cap. That's a broader change tosetBigDecimalValuesemantics, so it's left out of this PR.Tests
Six in
MaxNumberOfCharsTest(hash doesn't throw and stays aligned withXmlInteger; hash is scale-independent across the expansion threshold; a huge exponent isn't expanded; the integral setters reportXmlValueOutOfRangeException, use their own bound rather than the parse limit, and still reject out-of-range values at both ends) and one inGDateTestsfor the duration carry.Full suite: 3082 tests, 0 failures.
🤖 Generated with Claude Code