PureXML/Sources/Schema/SchemaDocument.swift
Mihaela Mihaljevic 8f2725b62a
fix(schema): reject an identity-constraint field that selects a non-simple node (#381)
XSD 1.0 §3.11.4 (cvc-identity-constraint.3) requires each key/keyref/unique
field to identify a node of simple type. A field that reaches an element
declared with no type (the ur-type anyType) or an inline complex type without
simple content does not, so the instance is invalid; the reference (libxml2)
rejects it identically. PureXML accepted such instances because its identity
validator could resolve a field target's type only for a global element reached
by a direct-child name, not for a local element reached through a path (the W3C
XSTS case idZ010 nests anyType fields several levels deep, e.g. a key field
./b/c where c has no type).

A compile-time pass (XSDParser.identityNonSimpleFieldKeys) resolves each field's
target element through its selector and field paths, by element-name steps, and
records the fields whose declared target is provably non-simple. The validator
rejects those at runtime unless the instance gives the node a simple type
through xsi:type, since cvc reads the node's governing type (a text-only anyType
element with xsi:type="xsd:int" is a valid field). The resolution is
conservative: any wildcard, descendant axis, predicate, ref, or named
non-builtin type leaves the field unflagged, so a valid field is never rejected.
The one residual divergence path, two same-name child elements of differing
simplicity in one inline model, is an Element-Declarations-Consistent violation
the compiler already rejects.

Verified over the full W3C XSTS corpus in release: invalid-instances-accepted
8 -> 7 (idZ010 now rejects), valid-instances-rejected held at 0 across all 14383
groups (no false positive), full suite (1837 tests), wasm, and lint all green.
An adversarial critic that read every file and probed for a false positive could
not construct one; the failure mode is under-rejection, the safe direction.
2026-06-29 09:08:25 +02:00

386 lines
21 KiB
Swift

public extension PureXML.Schema {
/// An error compiling or applying a schema.
enum SchemaError: Swift.Error, Equatable, Sendable, CustomStringConvertible {
case notASchema
/// A type inside `xs:redefine` does not redefine itself: its restriction or
/// extension base must name the type it redefines.
case redefineIncompatible(type: String)
/// An `xs:all` group violates its XSD 1.0 constraints: every member must be
/// an element with `maxOccurs` at most 1, and the group itself may occur at
/// most once.
case invalidAllGroup(reason: String)
/// A RELAX NG schema document does not match the RELAX NG grammar or
/// its restrictions (sections 3, 4.16-4.18).
case invalidRelaxNG(reason: String)
/// The compiled schema fails one or more consistency rules (a `final`
/// violation, an unfaithful restriction); every finding is carried, so a
/// schema with several problems reports them all at once.
case inconsistent([PureXML.Validation.ValidationError])
public var description: String {
switch self {
case .notASchema:
"the document is not an xs:schema"
case let .redefineIncompatible(type):
"redefined type '\(type)' must derive from itself"
case let .invalidAllGroup(reason):
"invalid xs:all group: \(reason)"
case let .invalidRelaxNG(reason):
"invalid RELAX NG schema: \(reason)"
case let .inconsistent(findings):
findings.map(\.description).joined(separator: "; ")
}
}
/// Every compile-time finding when the schema is inconsistent.
public var inconsistentFindings: [PureXML.Validation.ValidationError]? {
if case let .inconsistent(findings) = self { return findings }
return nil
}
}
/// A compiled XSD schema: its global element declarations and named-type
/// table, parsed from a schema document, used to validate instance documents.
struct Document: Sendable {
private let elements: [String: ElementType]
private let types: [String: ElementType]
private let constraints: [String: [IdentityConstraint]]
private let identityFieldTypes: [String: SimpleType]
private let identityFieldConstraints: [String: ValueConstraint]
private let identityNonSimpleFields: Set<String>
private let nillableElements: Set<String>
private let elementConstraints: [String: ValueConstraint]
private let abstractTypes: Set<String>
private let abstractElements: Set<String>
private let typeBlock: [String: Set<DerivationMethod>]
private let elementBlock: [String: Set<DerivationMethod>]
private let typeDerivation: [String: TypeDerivation]
private let unionMembers: [String: [String]]
private let targetNamespace: String?
private let globalAttributes: [String: AttributeUse]
/// Combines already-compiled declaration tables, for merging the schemas
/// an instance references through `xsi:schemaLocation`. No consistency
/// check runs: each component schema was validated when first compiled.
private init(
elements: [String: ElementType],
types: [String: ElementType],
constraints: [String: [IdentityConstraint]],
identityFieldTypes: [String: SimpleType] = [:],
identityFieldConstraints: [String: ValueConstraint] = [:],
identityNonSimpleFields: Set<String> = [],
nillableElements: Set<String>,
elementConstraints: [String: ValueConstraint],
abstractTypes: Set<String>,
abstractElements: Set<String>,
typeBlock: [String: Set<DerivationMethod>],
elementBlock: [String: Set<DerivationMethod>],
typeDerivation: [String: TypeDerivation],
unionMembers: [String: [String]],
targetNamespace: String?,
globalAttributes: [String: AttributeUse],
) {
self.elements = elements
self.types = types
self.constraints = constraints
self.identityFieldTypes = identityFieldTypes
self.identityFieldConstraints = identityFieldConstraints
self.identityNonSimpleFields = identityNonSimpleFields
self.nillableElements = nillableElements
self.elementConstraints = elementConstraints
self.abstractTypes = abstractTypes
self.abstractElements = abstractElements
self.typeBlock = typeBlock
self.elementBlock = elementBlock
self.typeDerivation = typeDerivation
self.unionMembers = unionMembers
self.targetNamespace = targetNamespace
self.globalAttributes = globalAttributes
}
/// This schema with `other`'s global declarations merged in; this schema's
/// own declarations win on any key conflict. Used to fold in the schemas an
/// instance points at, so a strict/lax wildcard can resolve elements
/// declared in another document.
public func merged(with other: Document) -> Document {
merging(other)
}
private func merging(_ other: Document) -> Document {
func mineFirst<V>(_ mine: [String: V], _ theirs: [String: V]) -> [String: V] {
mine.merging(theirs) { current, _ in current }
}
return Document(
elements: mineFirst(elements, other.elements),
types: mineFirst(types, other.types),
constraints: mineFirst(constraints, other.constraints),
identityFieldTypes: mineFirst(identityFieldTypes, other.identityFieldTypes),
identityFieldConstraints: mineFirst(identityFieldConstraints, other.identityFieldConstraints),
identityNonSimpleFields: identityNonSimpleFields.union(other.identityNonSimpleFields),
nillableElements: nillableElements.union(other.nillableElements),
elementConstraints: mineFirst(elementConstraints, other.elementConstraints),
abstractTypes: abstractTypes.union(other.abstractTypes),
abstractElements: abstractElements.union(other.abstractElements),
typeBlock: mineFirst(typeBlock, other.typeBlock),
elementBlock: mineFirst(elementBlock, other.elementBlock),
typeDerivation: mineFirst(typeDerivation, other.typeDerivation),
unionMembers: mineFirst(unionMembers, other.unionMembers),
targetNamespace: targetNamespace,
globalAttributes: mineFirst(globalAttributes, other.globalAttributes),
)
}
/// Compiles a schema document. `schemaLoader` resolves the
/// `schemaLocation` of `xs:include`, `xs:import`, and `xs:redefine` to
/// schema source; it returns nil (the default) when external schemas are
/// not available, which keeps compilation from reaching the filesystem or
/// network by default.
private init(compiled: XSDCompiled) throws {
elements = compiled.elements
types = compiled.types
constraints = compiled.constraints
identityFieldTypes = compiled.identityFieldTypes
identityNonSimpleFields = compiled.identityNonSimpleFields
identityFieldConstraints = compiled.identityFieldConstraints
nillableElements = compiled.nillableElements
elementConstraints = compiled.elementConstraints
abstractTypes = compiled.nsAbstractTypes
abstractElements = compiled.abstractElements
typeBlock = compiled.nsTypeBlock
elementBlock = compiled.nsElementBlock
typeDerivation = compiled.nsTypeDerivation
unionMembers = compiled.nsUnionMembers
targetNamespace = compiled.targetNamespace
globalAttributes = compiled.globalAttributes
// Schema consistency through the validation framework: every named
// type is checked by the composable rules (final respected, Particle
// Valid (Restriction)), and ALL findings are reported together, joined
// with the schema-validity findings gathered while parsing (malformed
// facet definitions and the like).
let consistencyFindings = PureXML.Validation.XSDSchema.consistencyErrors(
types: compiled.types,
typeDerivation: compiled.typeDerivation,
typeFinal: compiled.typeFinal,
)
let allFindings = compiled.schemaErrors + consistencyFindings
if !allFindings.isEmpty {
throw SchemaError.inconsistent(allFindings)
}
}
/// Validates an instance document against the schema, returning one located
/// ``PureXML/Validation/ValidationError`` per violation. Reports an error
/// when the root element has no global declaration.
public func validate(_ xml: String) throws -> [PureXML.Validation.ValidationError] {
try validate(PureXML.parse(xml))
}
/// Validates `xml`, additionally honoring the instance's
/// `xsi:schemaLocation` and `xsi:noNamespaceSchemaLocation` hints: each
/// referenced schema document is loaded through `schemaLoader`, compiled,
/// and its global declarations merged in (this schema's own declarations
/// win), so a strict or lax wildcard can resolve elements declared in
/// another document. With no hints this is `validate(_:)`.
public func validate(_ xml: String, schemaLoader: @escaping (String) -> String?) throws -> [PureXML.Validation.ValidationError] {
let node = try PureXML.parse(xml)
return merged(with: node, schemaLoader: schemaLoader).validate(node)
}
/// Merges global declarations from every schema document the instance
/// references through `xsi:schemaLocation` or
/// `xsi:noNamespaceSchemaLocation`.
private func merged(with node: PureXML.Model.Node, schemaLoader: @escaping (String) -> String?) -> Document {
var combined = self
for location in Self.hintedSchemaLocations(in: node) {
guard let source = schemaLoader(location),
let other = try? Document(source, schemaLoader: schemaLoader)
else { continue }
combined = combined.merging(other)
}
return combined
}
/// Validates `xml` against the schema while it is pulled event by event (the
/// libxml2 `xmlTextReader` model) rather than over a built tree. Memory is
/// bounded to the open-element stack. Content models, attributes, and simple
/// content are checked incrementally; document-scoped identity constraints
/// are checked once at the end from a parsed tree (the instance is parsed
/// again only when constraints are present).
public func validate(streaming xml: String, limits: PureXML.Parsing.Limits = .default) throws -> [PureXML.Validation.ValidationError] {
let identityNode = constraints.isEmpty ? nil : try PureXML.parse(xml)
return try validateStreaming(xml, limits: limits, identityNode: identityNode)
}
/// Validates `xml` while it is pulled event by event, additionally honoring
/// the instance's `xsi:schemaLocation` and
/// `xsi:noNamespaceSchemaLocation` hints through `schemaLoader`. See
/// ``validate(_:schemaLoader:)-(String)``.
public func validate(
streaming xml: String,
schemaLoader: @escaping (String) -> String?,
limits: PureXML.Parsing.Limits = .default,
) throws -> [PureXML.Validation.ValidationError] {
let node = try PureXML.parse(xml)
return try merged(with: node, schemaLoader: schemaLoader).validateStreaming(xml, limits: limits, identityNode: node)
}
private func validateStreaming(
_ xml: String,
limits: PureXML.Parsing.Limits,
identityNode: PureXML.Model.Node?,
) throws -> [PureXML.Validation.ValidationError] {
let validator = PureXML.Schema.ComplexValidator(
types: types,
globalAttributes: globalAttributes,
nillableElements: nillableElements,
elementConstraints: elementConstraints,
abstractTypes: abstractTypes,
typeBlock: typeBlock,
elementBlock: elementBlock,
typeDerivation: typeDerivation,
unionMembers: unionMembers,
)
var driver = PureXML.Validation.StreamingXSDValidator(
validator: validator,
rootElements: elements,
abstractElements: abstractElements,
targetNamespace: targetNamespace,
)
var reader = PureXML.Parsing.EventReader(xml, limits: limits)
while let event = try reader.next() {
driver.consume(event)
}
var errors = driver.finish()
errors += validator.idErrors()
if !constraints.isEmpty, let identityNode {
errors += identityErrors(in: identityNode)
}
return errors
}
/// Identity-constraint findings for an already-parsed instance tree.
private func identityErrors(in node: PureXML.Model.Node) -> [PureXML.Validation.ValidationError] {
guard case let .document(children) = node, let root = children.compactMap(\.element).first else {
return []
}
return PureXML.Schema.IdentityValidator(
constraints: constraints,
fieldTypes: identityFieldTypes,
fieldConstraints: identityFieldConstraints,
nonSimpleFields: identityNonSimpleFields,
types: types,
)
.validate(PureXML.Model.TreeNode(.element(root)), at: [.element(root.name.description)])
}
/// Validates an already-parsed node tree, so a caller can validate the same
/// (possibly recovered) tree it is editing. See ``validate(_:)-(String)``.
public func validate(_ node: PureXML.Model.Node) -> [PureXML.Validation.ValidationError] {
guard case let .document(children) = node, let root = children.compactMap(\.element).first else {
return [.init(reason: "the document has no root element", at: [])]
}
guard let declaration = globalElementDeclaration(for: root.name) ?? rootTypeOverride(for: root) else {
return [.init(reason: "no element declaration for '\(root.name.localName)'", at: [.element(root.name.description)])]
}
// A schema with a target namespace declares its global elements in that
// namespace; the root must be in it unless a merged schema declares it
// in another namespace, or xsi:type names the validating type.
if rootTypeOverride(for: root) == nil, rootNamespaceMismatch(root.name) {
let reason = "root element '\(root.name.localName)' is not in the schema target namespace '\(targetNamespace ?? "")'"
return [.init(reason: reason, at: [.element(root.name.description)])]
}
// An abstract element may not appear in an instance directly; only a
// concrete substitution-group member may stand in its place.
if abstractElements.contains(root.name.localName) {
return [.init(reason: "abstract element '\(root.name.localName)' must not appear in an instance", at: [.element(root.name.description)])]
}
let context = PureXML.Validation.XSDContext(
types: types,
constraints: constraints,
rootDeclaration: declaration,
nillableElements: nillableElements,
elementConstraints: elementConstraints,
abstractTypes: abstractTypes,
typeBlock: typeBlock,
elementBlock: elementBlock,
typeDerivation: typeDerivation,
unionMembers: unionMembers,
globalAttributes: globalAttributes,
identityFieldTypes: identityFieldTypes,
identityFieldConstraints: identityFieldConstraints,
identityNonSimpleFields: identityNonSimpleFields,
)
return PureXML.Validation.XSD.validator().errors(for: .element(root), in: context)
}
/// The global element declaration for a qualified instance name.
private func globalElementDeclaration(for name: PureXML.Model.QualifiedName) -> ElementType? {
if let declaration = types[PureXML.Schema.XSDParser.elementDeclarationKey(name)] {
return declaration
}
if name.namespaceURI == nil || name.namespaceURI?.isEmpty == true {
return elements[name.localName]
}
return nil
}
private func rootNamespaceMismatch(_ name: PureXML.Model.QualifiedName) -> Bool {
guard let target = targetNamespace, !target.isEmpty, name.namespaceURI != target else { return false }
return types[PureXML.Schema.XSDParser.elementDeclarationKey(name)] == nil
}
/// When the root has no global element declaration, an `xsi:type` attribute
/// can still name the type to validate against (Sun target-NS tests).
private func rootTypeOverride(for root: PureXML.Model.Element) -> ElementType? {
guard PureXML.Schema.ComplexValidator.xsiTypeAttributeValue(root) != nil else { return nil }
let bindings = PureXML.Schema.ComplexValidator.namespaceBindings(for: root)
guard let reference = PureXML.Schema.ComplexValidator.xsiTypeReference(root, namespaceBindings: bindings),
let type = PureXML.Schema.ComplexValidator.resolveNamedType(reference, in: types)
else { return nil }
return type
}
/// What the schema allows at the element a coding `path` addresses in
/// `tree`: the next child elements (the content-model follow-set), whether
/// the content may end there, and the declared attributes with their
/// required/present status. For editor completions and "what's missing".
/// Returns nil when the path does not address a declared element.
public func completions(at path: [PureXML.Validation.PathKey], in tree: PureXML.Model.TreeNode) -> Completions? {
guard let node = tree.node(at: path), case let .element(element) = node.node,
let type = CompletionEngine.elementType(at: path, elements: elements, types: types)
else {
return nil
}
return CompletionEngine.completions(for: element, type: type, types: types)
}
/// The quick-fixes the schema offers at the element a coding `path`
/// addresses in `tree`: add a required-but-absent attribute, or insert a
/// still-expected required child. Derived from the structured
/// ``completions(at:in:)``, with precise placement from the node's content
/// span, so the edits are exact, not guessed from a message.
public func quickFixes(at path: [PureXML.Validation.PathKey], in tree: PureXML.Model.TreeNode) -> [PureXML.QuickFix] {
guard let completions = completions(at: path, in: tree), let element = tree.node(at: path) else { return [] }
return PureXML.QuickFixEngine.fixes(from: completions, element: element)
}
}
}
public extension PureXML.Schema.Document {
/// Compiles a schema document. `schemaLoader` resolves the `schemaLocation` of
/// `xs:include`, `xs:import`, and `xs:redefine` to schema source; it returns
/// nil (the default) when external schemas are not available, which keeps
/// compilation from reaching the filesystem or network by default.
init(_ xsd: String, schemaLoader: @escaping (String) -> String? = { _ in nil }) throws {
try self.init(compiled: PureXML.Schema.XSDParser.parse(xsd, loader: schemaLoader))
}
/// Compiles several schema documents that jointly form one schema as a single
/// union (#161), so cross-document substitution-group membership is resolved.
/// Use this rather than compiling each document and merging the results, which
/// freezes each document's content models before the others are known.
init(composing sources: [String], schemaLoader: @escaping (String) -> String? = { _ in nil }) throws {
try self.init(compiled: PureXML.Schema.XSDParser.parse(union: sources, loader: schemaLoader))
}
}