PureXML/Sources/Schema/XSDSimpleParser.swift
Mihaela Mihaljevic 947278bb56 fix(regex): reject a malformed \p{...} property name in a pattern facet (#145)
A \p{...}/\P{...} property name that can be no XSD charProp under any
repertoire (empty, the bare prefix Is, a non-block name carrying a non-letter,
or Is followed by a non-block character) is now a schema-validity error,
distinct from a well-formed but unrecognised category or block name, which
stays a lenient engine limitation.

XSTS invalid-schemas-accepted 203 to 200, no other bucket moved.
2026-06-17 14:53:32 +02:00

308 lines
18 KiB
Swift

private typealias XSDNode = PureXML.Schema.XSDNode
private typealias SimpleType = PureXML.Schema.SimpleType
private typealias XSDContext = PureXML.Schema.XSDContext
private typealias BuiltinType = PureXML.Schema.BuiltinType
private typealias Facets = PureXML.Schema.Facets
extension PureXML.Schema {
/// Parses XSD simple types: atomic restrictions with the full facet set, and
/// the `list` and `union` varieties. Kept beside ``XSDParser``, sharing its
/// module-scope tree helpers and parsing context.
enum XSDSimpleParser {
static func simpleType(_ node: XSDTree, _ context: XSDContext) -> SimpleType {
if let list = XSDNode.firstChild(node, named: "list") {
return listType(list, context)
}
if let union = XSDNode.firstChild(node, named: "union") {
return unionType(union, context)
}
guard let restriction = XSDNode.firstChild(node, named: "restriction") else {
return SimpleType(base: .string)
}
// Resolve the base through simpleTypeReference so the built-in list
// datatypes (NMTOKENS, IDREFS, ENTITIES) keep their list variety: a
// length facet on one of them counts list items, not characters (#146).
// Restricting a user type inherits its base, facets, and variety. When the
// `base` attribute is absent the base is the inline `<simpleType>` child
// (an anonymous base); resolving it likewise preserves its list/union
// variety, so a length facet over an inline list base counts items.
let baseType: SimpleType = if let baseName = XSDNode.attribute(restriction, "base") {
simpleTypeReference(baseName, context)
} else if let inlineBase = XSDNode.firstChild(restriction, named: "simpleType") {
simpleType(inlineBase, context)
} else {
SimpleType(base: .string)
}
var facets = baseType.facets
// A restriction's own `enumeration` replaces the inherited set (the new
// enumeration is the value space, not a union with the base's), whereas
// `pattern` accumulates in groups: OR within a restriction step, AND
// across derivation (libxml2 `xmlSchemaValidateFacets`). Drop the inherited
// enumeration when this step declares its own.
if XSDNode.elementChildren(restriction).contains(where: { XSDNode.localName($0) == "enumeration" }) {
facets.enumeration = nil
}
applyFacets(restriction, into: &facets)
for error in facetDefinitionErrors(restriction, base: baseType) + patternErrors(restriction) {
context.diagnostics.report(error, at: restriction)
}
return SimpleType(base: baseType.base, facets: facets, variety: baseType.variety, isBuiltinList: baseType.isBuiltinList)
}
/// Schema-validity findings for the `pattern` facets declared on one
/// `restriction`: each `pattern` value must be a valid XSD regular
/// expression. The pattern was only compiled lazily at instance time
/// (`try?`), so an unparseable one was silently ignored and the schema
/// accepted. Only the unambiguous structural errors reject (a quantifier
/// with nothing to repeat, `?a`; unbalanced parentheses, `((a)`); see
/// `rejectsPattern`.
static func patternErrors(_ restriction: XSDTree) -> [String] {
var errors: [String] = []
for facet in XSDNode.elementChildren(restriction) where XSDNode.localName(facet) == "pattern" {
guard facet.name?.namespaceURI == PureXML.Schema.XSDParser.xsdNamespace else { continue }
let value = XSDNode.attribute(facet, "value") ?? ""
do {
_ = try PureXML.Regex.Pattern(value)
} catch let error as PureXML.Regex.RegexError where rejectsPattern(error) {
errors.append("pattern '\(value)' is not a valid regular expression: \(error)")
} catch {
// A construct the engine does not support (`.empty`, `.unsupported`
// for an untabulated `\p{Is...}` block, `.badQuantifier` for the
// lenient `{,m}` form) is a valid XSD pattern, not a schema error.
}
}
return errors
}
/// Whether a regex-compile failure is a genuine syntax error (so the schema
/// is invalid), as opposed to an engine limitation on an otherwise-valid XSD
/// pattern. Conservative: only the unambiguous structural errors reject.
private static func rejectsPattern(_ error: PureXML.Regex.RegexError) -> Bool {
switch error {
case .unbalanced, .danglingQuantifier, .reversedRange, .emptyClass, .reversedQuantifier,
.incompleteEscape, .unterminatedClass, .invalidProperty:
true
case .unsupported, .badQuantifier, .badEscape, .badClass:
false
}
}
/// Schema-validity findings for the constraining facets declared on one
/// `restriction` (XSD Part 2 Datatypes 4.3): the length-family rules plus
/// the value-bound rules, both limited to the facets declared on this step
/// so an inherited facet is never flagged here.
static func facetDefinitionErrors(_ restriction: XSDTree, base: SimpleType) -> [String] {
var errors = countFacetErrors(restriction) + boundFacetErrors(restriction, base: base)
errors += whiteSpaceRestrictionErrors(restriction, base: base)
errors += facetRestrictionErrors(restriction, base: base)
errors += facetApplicabilityErrors(restriction, base: base)
return errors
}
/// The `length`, `minLength`, `maxLength`, `totalDigits`, and `fractionDigits`
/// facets on a restriction must restrict or equal the base type's facets.
private static func facetRestrictionErrors(_ restriction: XSDTree, base: SimpleType) -> [String] {
var errors: [String] = []
var localFacets = Facets()
applyFacets(restriction, into: &localFacets)
if let baseLength = base.facets.length, let localLength = localFacets.length, localLength != baseLength {
errors.append("facet 'length' (\(localLength)) must equal base 'length' (\(baseLength))")
}
if let baseMinLength = base.facets.minLength, let localMinLength = localFacets.minLength, localMinLength < baseMinLength {
errors.append("facet 'minLength' (\(localMinLength)) cannot be less than base 'minLength' (\(baseMinLength))")
}
if let baseMaxLength = base.facets.maxLength, let localMaxLength = localFacets.maxLength, localMaxLength > baseMaxLength {
errors.append("facet 'maxLength' (\(localMaxLength)) cannot be greater than base 'maxLength' (\(baseMaxLength))")
}
if let baseTotalDigits = base.facets.totalDigits, let localTotalDigits = localFacets.totalDigits, localTotalDigits > baseTotalDigits {
errors.append("facet 'totalDigits' (\(localTotalDigits)) cannot be greater than base 'totalDigits' (\(baseTotalDigits))")
}
let baseFractionDigits = base.facets.fractionDigits ?? (base.base.derives(from: .integer) ? 0 : nil)
if let baseFractionDigits, let localFractionDigits = localFacets.fractionDigits, localFractionDigits > baseFractionDigits {
errors.append("facet 'fractionDigits' (\(localFractionDigits)) cannot be greater than base 'fractionDigits' (\(baseFractionDigits))")
}
return errors
}
private static func facetApplicabilityErrors(_ restriction: XSDTree, base: SimpleType) -> [String] {
guard case .atomic = base.variety else { return [] }
var errors: [String] = []
let allowed = allowedFacets(for: base.base.primitive)
let facetNames: Set = [
"length", "minLength", "maxLength", "pattern", "enumeration", "whiteSpace",
"maxInclusive", "maxExclusive", "minInclusive", "minExclusive", "totalDigits", "fractionDigits",
]
for child in XSDNode.elementChildren(restriction) {
guard child.name?.namespaceURI == PureXML.Schema.XSDParser.xsdNamespace else { continue }
guard let name = XSDNode.localName(child), facetNames.contains(name) else { continue }
if !allowed.contains(name) {
errors.append("facet '\(name)' does not apply to base type '\(base.base.rawValue)'")
}
}
return errors
}
private static func allowedFacets(for primitive: Primitive) -> Set<String> {
switch primitive {
case .string, .anyURI, .hexBinary, .base64Binary, .name, .ncName, .nmtoken, .language, .qName, .notation:
["length", "minLength", "maxLength", "pattern", "enumeration", "whiteSpace"]
case .boolean:
["pattern", "whiteSpace"]
case .decimal, .integer:
["totalDigits", "fractionDigits", "pattern", "whiteSpace", "enumeration", "maxInclusive", "maxExclusive", "minInclusive", "minExclusive"]
case .double, .float, .duration, .dateKind:
["pattern", "whiteSpace", "enumeration", "maxInclusive", "maxExclusive", "minInclusive", "minExclusive"]
}
}
/// The `whiteSpace` facet may only strengthen, never relax, the base type's
/// effective whiteSpace (XSD Part 2 §4.3.6, the facet's valid-restriction
/// rule): the order is `preserve` < `replace` < `collapse`, so a base fixed
/// to `collapse` admits only `collapse`, and a base of `replace` admits
/// `replace` or `collapse`. Checked for an atomic base only; the base's
/// effective whiteSpace is its own facet, falling back to its built-in
/// intrinsic. If the base type has not resolved its own whiteSpace the check
/// can only under-detect, never reject a valid restriction.
private static func whiteSpaceRestrictionErrors(_ restriction: XSDTree, base: SimpleType) -> [String] {
guard case .atomic = base.variety, let declared = declaredWhiteSpace(restriction) else { return [] }
let baseWhiteSpace = base.facets.whiteSpace ?? base.base.whiteSpace
guard whiteSpaceStrength(declared) < whiteSpaceStrength(baseWhiteSpace) else { return [] }
return ["whiteSpace '\(whiteSpaceName(declared))' may not relax the base type's whiteSpace '\(whiteSpaceName(baseWhiteSpace))'"]
}
private static func declaredWhiteSpace(_ restriction: XSDTree) -> WhiteSpace? {
for facet in XSDNode.elementChildren(restriction) where XSDNode.localName(facet) == "whiteSpace" {
guard facet.name?.namespaceURI == PureXML.Schema.XSDParser.xsdNamespace else { continue }
return whiteSpace(XSDNode.attribute(facet, "value"))
}
return nil
}
private static func whiteSpaceStrength(_ whiteSpace: WhiteSpace) -> Int {
switch whiteSpace {
case .preserve: 0
case .replace: 1
case .collapse: 2
}
}
private static func whiteSpaceName(_ whiteSpace: WhiteSpace) -> String {
switch whiteSpace {
case .preserve: "preserve"
case .replace: "replace"
case .collapse: "collapse"
}
}
/// Length-family findings: `length`, `minLength`, `maxLength`,
/// `totalDigits`, and `fractionDigits` must be `nonNegativeInteger`
/// (`totalDigits` a `positiveInteger`), `length` may not co-occur with
/// `minLength`/`maxLength`, and `minLength` <= `maxLength`,
/// `fractionDigits` <= `totalDigits`. Reads the raw lexical, since numeric
/// facet parsing silently drops a malformed value.
private static func countFacetErrors(_ restriction: XSDTree) -> [String] {
var errors: [String] = []
var values: [String: String] = [:] // facet name -> canonical digit string
let countFacets: Set = ["length", "minLength", "maxLength", "totalDigits", "fractionDigits"]
for facet in XSDNode.elementChildren(restriction) {
guard facet.name?.namespaceURI == PureXML.Schema.XSDParser.xsdNamespace else { continue }
guard let name = XSDNode.localName(facet), countFacets.contains(name) else { continue }
let raw = XSDNode.attribute(facet, "value")
guard let canonical = raw.flatMap(canonicalNonNegativeInteger) else {
errors.append("facet '\(name)' value '\(raw ?? "")' is not a valid nonNegativeInteger")
continue
}
if name == "totalDigits", canonical == "0" {
errors.append("facet 'totalDigits' value '\(raw ?? "")' must be a positiveInteger")
}
values[name] = canonical
}
if values["length"] != nil, values["minLength"] != nil {
errors.append("facets 'length' and 'minLength' may not both be specified")
}
if values["length"] != nil, values["maxLength"] != nil {
errors.append("facets 'length' and 'maxLength' may not both be specified")
}
if let low = values["minLength"], let high = values["maxLength"], greater(low, than: high) {
errors.append("facet 'minLength' (\(low)) exceeds 'maxLength' (\(high))")
}
if let fraction = values["fractionDigits"], let total = values["totalDigits"], greater(fraction, than: total) {
errors.append("facet 'fractionDigits' (\(fraction)) exceeds 'totalDigits' (\(total))")
}
return errors
}
/// Value-bound findings: each `minInclusive`/`maxInclusive`/`minExclusive`/
/// `maxExclusive` and `enumeration` value must be a valid value of the base
/// type; `minInclusive` excludes `minExclusive` and `maxInclusive` excludes
/// `maxExclusive`; and the lower bound may not exceed the upper bound (nor
/// equal it when either side is exclusive, which is an empty value space).
/// Ordering is compared in the base primitive's value space.
private static func boundFacetErrors(_ restriction: XSDTree, base: SimpleType) -> [String] {
var errors: [String] = []
var bounds: [String: String] = [:]
for facet in XSDNode.elementChildren(restriction) {
guard facet.name?.namespaceURI == PureXML.Schema.XSDParser.xsdNamespace else { continue }
guard let name = XSDNode.localName(facet) else { continue }
let raw = XSDNode.attribute(facet, "value") ?? ""
switch name {
case "minInclusive", "maxInclusive", "minExclusive", "maxExclusive":
if base.validate(raw) != nil {
errors.append("facet '\(name)' value '\(raw)' is not a valid value of the base type")
} else {
bounds[name] = raw
}
case "enumeration":
if base.validate(raw) != nil {
errors.append("enumeration value '\(raw)' is not a valid value of the base type")
}
default:
break
}
}
if bounds["minInclusive"] != nil, bounds["minExclusive"] != nil {
errors.append("facets 'minInclusive' and 'minExclusive' may not both be specified")
}
if bounds["maxInclusive"] != nil, bounds["maxExclusive"] != nil {
errors.append("facets 'maxInclusive' and 'maxExclusive' may not both be specified")
}
errors += boundOrderErrors(bounds, base: base)
return errors
}
/// The lower-versus-upper-bound ordering finding, if any, comparing in the
/// base primitive's value space. Skipped for an unordered primitive (the
/// `ordered` lookup returns nil), so only a defined order is checked.
private static func boundOrderErrors(_ bounds: [String: String], base: SimpleType) -> [String] {
guard let low = bounds["minInclusive"] ?? bounds["minExclusive"],
let high = bounds["maxInclusive"] ?? bounds["maxExclusive"]
else { return [] }
if case .duration = base.base.primitive {
guard let lowDuration = DurationValue(low), let highDuration = DurationValue(high) else { return [] }
let order = lowDuration.compare(to: highDuration)
if order == .greaterThan {
return ["lower bound '\(low)' exceeds upper bound '\(high)'"]
}
let exclusive = bounds["minExclusive"] != nil || bounds["maxExclusive"] != nil
if exclusive {
if order == .equal {
return ["bounds '\(low)' and '\(high)' describe an empty value space"]
}
}
return []
}
guard let lowValue = base.base.primitive.ordered(low),
let highValue = base.base.primitive.ordered(high)
else { return [] }
if lowValue > highValue {
return ["lower bound '\(low)' exceeds upper bound '\(high)'"]
}
let exclusive = bounds["minExclusive"] != nil || bounds["maxExclusive"] != nil
if lowValue == highValue, exclusive {
return ["bounds '\(low)' and '\(high)' describe an empty value space"]
}
return []
}
}
}