fix(xslt): no fatal traps on extreme format-number and xsl:number values

format-number of a value at or beyond 2^53 ran an unguarded
Double-to-Int conversion, a fatal trap reachable from user data; a
fraction picture beyond 15 places overflowed the scale conversion the
same way; and xsl:number value= beyond 2^53 trapped identically.
Digit extraction clamps fraction places to 15 (the 2^53 exactness
bound), pads back to the picture's minimum, renders out-of-range
integers through the exponent expander, and xsl:number falls back to
plain number rendering outside [1, 2^53). Sort-key rows always carry
one key per sort. Regression tests cover all three trap paths.
This commit is contained in:
Mihaela Mihaljevic 2026-06-10 15:35:36 +02:00
parent 3d26db555f
commit b84fdd00ef
7 changed files with 51 additions and 9 deletions

View File

@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Scanner-side validity findings are now located structured values (validation-framework audit follow-up): `DocumentType.validityFindings` carries `ValidityFinding` values (reason plus the declared name they are about) instead of strings, `DTDSchema.declarationErrors` holds ready `ValidationError`s, and every declaration-level finding renders at its declaration's coding path (`x`, `r/@a`) instead of "at root of document", matching the XSD consistency-errors precedent. Finding texts are unchanged.
### Fixed
- format-number and xsl:number no longer trap on extreme values (#130 critic loop): format-number of a value at or beyond 2^53 rendered through an unguarded Double-to-Int conversion, a fatal trap reachable from user data (format-number(99999999999999999999 * 99999999999999999999, '#,##0') crashed the host); a fraction picture with more than 15 places overflowed the scale conversion the same way; and xsl:number value= beyond 2^53 trapped identically. Digit extraction now clamps fraction places to 15 (the 2^53 exactness bound), pads back to the picture's requested minimum, renders out-of-range integers through the exponent expander (digits stay exact), and xsl:number falls back to plain XPath number rendering outside [1, 2^53). Regression tests cover all three paths.
### Added
- Critic-loop hardening of the xalan burn-down (#130): raw-output marker handling is now gated on the stylesheet actually mentioning `disable-output-escaping`, so a source document that happens to contain the private-use marker characters U+E000/U+E001 passes through every transform untouched (previously they were stripped, and text between them unescaped, unconditionally), and the text output method only removes markers without "unescaping" content that was never escaped; the pattern-matching function table is built once per transform instead of once per (template, node) membership test; and the d-o-e, CDATA "]]>" splitting, simplified-syntax, html default-method/META, per-document key(), and attribute-axes behaviors gain focused unit tests that run in CI without the opt-in corpus (eight new tests).

View File

@ -125,7 +125,7 @@ flowchart TB
PureXML is a working, dependency-free XML library today: parse, emit, validate,
query, and stream documents on macOS, Linux, Windows, and WASM. The test suite
currently runs **963 tests in 166 suites** (`swift test`).
currently runs **966 tests in 166 suites** (`swift test`).
### Shipped (libxml2-aligned surface)

View File

@ -141,7 +141,10 @@ extension PureXML.XSLT.Transformer {
}
if let valueExpression = spec.value {
let number = value(valueExpression, context)?.number ?? .nan
guard number.isFinite, number.rounded() >= 1 else {
// Outside [1, 2^53) the number renders as a plain XPath number
// (below one there is nothing to format; above, the Int
// conversion would trap and a Double holds no fraction anyway).
guard number.isFinite, number.rounded() >= 1, number.rounded() < 9_007_199_254_740_992 else {
return PureXML.XPath.Value.format(number)
}
return XSLTNumbering.format([Int(number.rounded())], spec.format, grouping)

View File

@ -111,12 +111,16 @@ extension PureXML.XSLT {
private static func render(_ value: Double, _ layout: NumberLayout, _ symbols: DecimalFormat) -> String {
// One scaled rounding, then pure integer digit extraction: repeated
// float multiplication would re-introduce representation error
// (0.4812 must render 4812, not 481199...).
// (0.4812 must render 4812, not 481199...). The scaled product must
// stay under 2^53 for exact integer extraction, so the picture's
// fraction places are clamped to 15 and padded back to the
// requested minimum.
let places = Swift.min(layout.maxFraction, 15)
var scale = 1.0
for _ in 0 ..< layout.maxFraction {
for _ in 0 ..< places {
scale *= 10
}
let (integerDigits, allFractionDigits) = digits(value, scale: scale, places: layout.maxFraction)
let (integerDigits, allFractionDigits) = digits(value, scale: scale, places: places)
var paddedInteger = integerDigits
while paddedInteger.count < Swift.max(1, layout.minInteger) {
paddedInteger = "0" + paddedInteger
@ -128,17 +132,24 @@ extension PureXML.XSLT {
while fractionDigits.count > layout.minFraction, fractionDigits.hasSuffix("0") {
fractionDigits.removeLast()
}
while fractionDigits.count < layout.minFraction {
fractionDigits += "0"
}
let fractionText = digitsToSymbols(fractionDigits, symbols)
return fractionText.isEmpty ? integerText : integerText + String(symbols.decimalSeparator) + fractionText
}
/// The integer and fraction digit strings of `value` carried to
/// `places` fraction digits, via a single scaled rounding when the
/// magnitude fits an Int exactly.
/// magnitude fits an Int exactly. Beyond 2^53 a Double holds no
/// fraction anyway, so the integer digits come from the exponent
/// expander and the fraction is zeros: no Double-to-Int conversion
/// here may trap, whatever the value or the picture.
private static func digits(_ value: Double, scale: Double, places: Int) -> (String, String) {
let scaled = (value * scale).rounded()
guard scaled < 9_007_199_254_740_992 else { // 2^53: beyond it Double has no fraction anyway
return (String(Int(value)), String(repeating: "0", count: places))
guard scaled.isFinite, scaled < 9_007_199_254_740_992, scale < 9_007_199_254_740_992 else {
let integer = PureXML.XPath.Value.format(value.rounded(.towardZero))
return (integer, String(repeating: "0", count: places))
}
let total = Int(scaled)
let divisor = Int(scale)

View File

@ -12,7 +12,7 @@ extension PureXML.XSLT.Transformer {
// context (its position and size in the selection, the caller's
// variables and bindings), so position()-based keys work.
let keyed = nodes.enumerated().map { offset, xnode -> SortEntry in
guard let owner = Self.ownerNode(xnode) else { return SortEntry(offset: offset, node: xnode, keys: []) }
guard let owner = Self.ownerNode(xnode) else { return SortEntry(offset: offset, node: xnode, keys: sorts.map { _ in "" }) }
var keyContext = XSLTContext(
node: owner,
current: xnode.treeNode == nil ? xnode : nil,

View File

@ -90,4 +90,19 @@ struct XSLTDecimalFormatTests {
func test_fractionExact() throws {
#expect(try transform(formatNumber("format-number(87504.4812, '000,000.000###')"), "<x/>") == "087,504.4812")
}
@Test("Values beyond 2^53 format without trapping, digits exact")
func test_hugeValues() throws {
let huge = "99999999999999999999 * 99999999999999999999"
#expect(try transform(formatNumber("format-number(\(huge), '0')"), "<x/>")
== "1" + String(repeating: "0", count: 40))
#expect(try transform(formatNumber("format-number(\(huge), '#,##0')"), "<x/>")
== "10," + Array(repeating: "000", count: 13).joined(separator: ","))
}
@Test("A fraction picture beyond Double precision clamps to 18 places and pads")
func test_absurdFractionPicture() throws {
let result = try transform(formatNumber("format-number(0.5, '0.0000000000000000000000000')"), "<x/>")
#expect(result == "0.5" + String(repeating: "0", count: 24))
}
}

View File

@ -72,4 +72,13 @@ struct XSLTNumberingTests {
let zero = try transform("<xsl:number value=\"0\" format=\"(I) \"/>", source: "<doc><mark/></doc>")
#expect(zero == "<out>0 </out>")
}
@Test("A value beyond 2^53 renders as a plain number without trapping")
func test_hugeValue() throws {
let result = try transform(
"<xsl:number value=\"99999999999999999999 * 99999999999999999999\"/>",
source: "<doc><mark/></doc>",
)
#expect(result == "<out>1" + String(repeating: "0", count: 40) + " </out>")
}
}