PureXML/Sources/Schema/SchemaDocument.swift
Mihaela Mihaljevic 43456029ac fix(schema): enforce element-level block against xsi:type substitution
The block on an element declaration (distinct from block on its type) was
parsed into the derivation tables but never threaded to the instance validator,
so an xsi:type that substitutes a type derived by a blocked method was accepted.
elementBlock now flows through XSDCompiled, SchemaDocument, the validation
context, and both ComplexValidator construction sites to blockedSubstitutionError,
which checks the element's own block alongside the type's. It also resolves the
element-ref type indirection (a ref'd child carries an `element:foo` key that
resolves through `types` to the concrete declared type) so the derivation chain
and block checks fire for referenced children, not only the document root.

XSTS: invalid instances accepted 261 to 240. Baseline updated; regression tests
cover element block rejecting a restriction xsi:type, the no-block control, and
an unrelated block not over-rejecting.

Refs #147, #129.
2026-06-13 18:04:25 +02:00

175 lines
9.4 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([String])
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.joined(separator: "; ")
}
}
}
/// 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 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 targetNamespace: String?
/// 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.
public init(_ xsd: String, schemaLoader: @escaping (String) -> String? = { _ in nil }) throws {
let compiled = try XSDParser.parse(xsd, loader: schemaLoader)
elements = compiled.elements
types = compiled.types
constraints = compiled.constraints
nillableElements = compiled.nillableElements
elementConstraints = compiled.elementConstraints
abstractTypes = compiled.abstractTypes
abstractElements = compiled.abstractElements
typeBlock = compiled.typeBlock
elementBlock = compiled.elementBlock
typeDerivation = compiled.typeDerivation
targetNamespace = compiled.targetNamespace
// 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.
let findings = PureXML.Validation.XSDSchema.consistencyErrors(
types: compiled.types,
typeDerivation: compiled.typeDerivation,
typeFinal: compiled.typeFinal,
)
if !findings.isEmpty {
throw SchemaError.inconsistent(findings.map { String(describing: $0) })
}
}
/// 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` 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; document-scoped identity constraints (which need the
/// whole tree) stay on ``validate(_:)-(String)``.
public func validate(streaming xml: String, limits: PureXML.Parsing.Limits = .default) throws -> [PureXML.Validation.ValidationError] {
let validator = PureXML.Schema.ComplexValidator(
types: types,
nillableElements: nillableElements,
elementConstraints: elementConstraints,
abstractTypes: abstractTypes,
typeBlock: typeBlock,
elementBlock: elementBlock,
typeDerivation: typeDerivation,
)
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)
}
return driver.finish()
}
/// 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 = elements[root.name.localName] 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.
if let target = targetNamespace, !target.isEmpty, root.name.namespaceURI != target {
return [.init(reason: "root element '\(root.name.localName)' is not in the schema target namespace '\(target)'", 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,
)
return PureXML.Validation.XSD.validator().errors(for: .element(root), in: context)
}
/// 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)
}
}
}