PureXML/Sources/Schema/Primitive.swift
Mihaela Mihaljevic 7be7fa25f3
fix(schema): match a NaN value against a NaN enumeration (value-space identity) (#376)
OrderedValue.== compared doubles with IEEE equality (NaN != NaN), so a NaN value never matched a NaN enumeration member and was rejected: <v>NaN</v> against xs:double restricted by <xs:enumeration value="NaN"/> is valid, and PureXML rejected it (a cardinal-rule false positive). NaN is now treated as identical to itself in value-space equality, the identity the reference (libxml2) and XSD 1.1 use throughout; an adversarial critic flagged the shared comparison (also used by key/unique and fixed), so the key/fixed behaviour was verified against xmllint: libxml2 collides two NaN unique keys and matches fixed="NaN", i.e. the same identity, so one identity == matches the reference everywhere. Ordering (<) keeps NaN incomparable, so range facets are unaffected. Found by a new generated-value facet differential (FacetValidationDifferentialTests, opt-in via FACET_DIFF) over ~10000 datatype/facet values: one FP found, zero after; the four W3C XSTS buckets held, full suite passes, SchemaDoubleEnumerationTests locks all three relations.
2026-06-29 05:20:07 +02:00

135 lines
5.3 KiB
Swift

extension PureXML.Schema {
/// A value compared for the ordering facets (`minInclusive` and friends).
enum OrderedValue: Equatable, Comparable {
case decimal(DecimalValue)
case double(Double)
case dateTime(DateTimeValue)
static func < (lhs: OrderedValue, rhs: OrderedValue) -> Bool {
switch (lhs, rhs) {
case let (.decimal(left), .decimal(right)): left < right
case let (.double(left), .double(right)): left < right
case let (.dateTime(left), .dateTime(right)): left < right
default: false
}
}
/// Value-space equality. A `double`/`float` value matches by IDENTITY, under which
/// `NaN` is identical to `NaN` (IEEE `==` reports `NaN != NaN`). This is what every
/// value-equality facet wants and what the reference (libxml2) and XSD 1.1 do: an
/// `enumeration` listing `NaN` admits `NaN`, two `NaN`-valued `key`/`unique` fields
/// collide as duplicates, and `fixed="NaN"` matches `NaN`. Ordering (`<`) keeps `NaN`
/// incomparable, so the range facets are unaffected.
static func == (lhs: OrderedValue, rhs: OrderedValue) -> Bool {
switch (lhs, rhs) {
case let (.decimal(left), .decimal(right)): left == right
case let (.double(left), .double(right)): left == right || (left.isNaN && right.isNaN)
case let (.dateTime(left), .dateTime(right)): left == right
default: false
}
}
}
/// An XSD primitive value space: the lexical recognizer, and for the ordered
/// spaces a value usable by the ordering facets.
enum Primitive: Sendable {
case string
case boolean
case decimal
case integer
case double
case float
case duration
case dateKind(DateKind)
case hexBinary
case base64Binary
case anyURI
case qName
case name
case ncName
case nmtoken
case language
case notation
func isValid(_ value: String) -> Bool {
stringValidity(value) ?? numericValidity(value)
}
/// Whether two valid values of this primitive compare by their
/// whitespace-processed lexical form rather than by a value space. These
/// are exactly the `default` cases of `SimpleType.atomicValueMatches`:
/// every primitive except the numeric, date, and boolean spaces, which
/// compare two distinct lexical forms (`3.0`/`3`, `true`/`1`) as equal.
var comparesLexically: Bool {
switch self {
case .decimal, .integer, .double, .float, .boolean, .dateKind: false
default: true
}
}
/// Validity for the string-like and boolean spaces; nil when this primitive
/// is handled by ``numericValidity(_:)``.
private func stringValidity(_ value: String) -> Bool? {
switch self {
case .string, .anyURI: true
case .boolean: Lexical.isBoolean(value)
case .qName, .notation: Lexical.isQName(value)
case .name: Lexical.isName(value)
case .ncName: Lexical.isNCName(value)
case .nmtoken: Lexical.isNMToken(value)
case .language: Lexical.isLanguage(value)
default: nil
}
}
private func numericValidity(_ value: String) -> Bool {
switch self {
case .decimal: DecimalValue(value, allowFraction: true) != nil
case .integer: DecimalValue(value, allowFraction: false) != nil
case .double, .float: Lexical.isFloating(value)
case .duration: Lexical.isDuration(value)
case let .dateKind(kind): DateTimeParser.parse(value, kind: kind) != nil
case .hexBinary: Lexical.isHexBinary(value)
case .base64Binary: Lexical.isBase64Binary(value)
default: false
}
}
/// The ordering value for a lexically valid string, or nil for the
/// unordered spaces.
func ordered(_ value: String) -> OrderedValue? {
switch self {
case .decimal: DecimalValue(value, allowFraction: true).map(OrderedValue.decimal)
case .integer: DecimalValue(value, allowFraction: false).map(OrderedValue.decimal)
case .double, .float: Self.floatingValue(value).map(OrderedValue.double)
case let .dateKind(kind): DateTimeParser.parse(value, kind: kind).map(OrderedValue.dateTime)
default: nil
}
}
private static func floatingValue(_ value: String) -> Double? {
switch value {
case "INF": .infinity
case "-INF": -.infinity
case "NaN": .nan
default: Double(value)
}
}
/// The unit `length`, `minLength`, and `maxLength` count: octets for binary
/// types, characters otherwise.
func measuredLength(_ value: String) -> Int {
switch self {
case .hexBinary: value.count / 2
case .base64Binary: Self.base64Octets(value)
default: value.count
}
}
private static func base64Octets(_ value: String) -> Int {
let padding = value.reversed().prefix { $0 == "=" }.count
return value.count / 4 * 3 - padding
}
}
}