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:
parent
3d26db555f
commit
b84fdd00ef
@ -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).
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>")
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user