fix(schema): streaming validator applies default/fixed value constraints

The streaming XSD validator ignored an element's default/fixed value constraint
in simple-content validation, so an empty element with a value constraint was
validated against the empty string (a false positive), and a present fixed
value was compared as a raw string rather than in the type's value space.

Mirror the tree path's validateChild for the simple-typed element:

- an empty element takes its constraint value via a shared effectiveSimpleText
  helper both paths use; the constraint comes from the matched particle (keyed
  by namespaced identity), not the flat by-local-name elementConstraints map,
  so same-local-name elements in different types do not collide.
- a present fixed value is compared in the type's value space and against the
  matched particle's fixed value (elementFixedErrors with valueType and
  particleFixed), not a raw string compare ("05" equals int fixed "5").

The particle constraint is looked up from the parent's content model at
element-close time. Streaming now agrees with the tree-path oracle on value
constraints, verified by an adversarial differential (13 configurations).
No XSTS bucket changes; gate held at 0/37/0/26.
This commit is contained in:
Mihaela Mihaljevic 2026-06-20 17:18:49 +02:00
parent f9b56077cc
commit aeff7869f9
6 changed files with 196 additions and 12 deletions

View File

@ -28,6 +28,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- The streaming XSD validator now applies an element's `default`/`fixed` value
constraint to its simple content (#200): an empty element takes its constraint
value, and that value (not the empty string) is validated against the type
(cvc-elt.5.1.2 / cvc-elt.5.2.2.2). The constraint comes from the matched particle
(keyed by namespaced identity), not the flat by-local-name map, so two same-local-
name elements in different types do not collide. Both paths share one
`effectiveSimpleText(particleConstraint:)` helper, so an empty element with a valid
default is no longer falsely rejected and streaming agrees with the tree path.
- The streaming XSD validator now agrees with the tree validator on `xsi:type` and
type resolution (#186). Type resolution is keyed by namespaced identity
(`{ns}local`) through in-scope prefix bindings accumulated down the element stack:

View File

@ -104,6 +104,18 @@ public extension PureXML.Schema {
return []
}
/// The text an element's simple content is validated against: its character
/// content, or, when the element is empty, its `default`/`fixed` value (an
/// empty element takes that value, and the value, not the empty string, must
/// satisfy the type). The matched particle's constraint takes precedence over
/// the by-local-name `elementConstraints` map, exactly as the tree path's
/// `validateChild` resolves it.
func effectiveSimpleText(for element: PureXML.Model.Element, particleConstraint: ValueConstraint? = nil) -> String {
let text = Self.rawTextContent(element)
let constraint = particleConstraint ?? elementConstraints[element.name.localName]
return text.isEmpty ? (constraint?.value ?? text) : text
}
/// The error from a `fixed` element value constraint: the element's text
/// must equal the fixed value in the element type's value space when known.
func elementFixedErrors(
@ -314,9 +326,7 @@ public extension PureXML.Schema {
// (not the empty string) is what must be valid against the type,
// including any xsi:type override already resolved into `simple`.
let text = Self.rawTextContent(child)
let constraint = particleConstraint ?? elementConstraints[child.name.localName]
let effective = text.isEmpty ? (constraint?.value ?? text) : text
if let error = simple.validate(effective) {
if let error = simple.validate(effectiveSimpleText(for: child, particleConstraint: particleConstraint)) {
errors.append(XSDFailure(reason: "'\(child.name.localName)': \(error)", at: path))
}
recordIDs(simple, value: text, at: path)

View File

@ -75,6 +75,29 @@ extension PureXML.Schema.ComplexValidator {
}
}
/// The per-particle `default`/`fixed` value constraint of each named element in a
/// content model, keyed by namespaced identity. The streaming validator uses this
/// to apply a child's particle constraint (not the by-local-name `elementConstraints`
/// map, which collides across same-local-name elements in different types).
static func elementValueConstraints(in term: PureXML.Schema.Term) -> [String: PureXML.Schema.ValueConstraint] {
var result: [String: PureXML.Schema.ValueConstraint] = [:]
collectValueConstraints(term, into: &result)
return result
}
fileprivate static func collectValueConstraints(_ term: XSDTerm, into result: inout [String: PureXML.Schema.ValueConstraint]) {
switch term {
case let .element(name, _, _, valueConstraint, _, _):
if let valueConstraint { result[key(name)] = valueConstraint }
case let .group(group):
for member in group.particles {
collectValueConstraints(member.term, into: &result)
}
case .wildcard:
break
}
}
static func key(_ name: PureXML.Model.QualifiedName) -> String {
"{\(name.namespaceURI ?? "")}\(name.localName)"
}

View File

@ -5,10 +5,14 @@ public extension PureXML.Schema {
struct ResolvedElement: PureXML.Validation.Validatable {
public let element: PureXML.Model.Element
public let type: ElementType
/// The matched particle's `default`/`fixed` value constraint, when the element
/// is a content-model child, so an empty element validates that value.
public let valueConstraint: ValueConstraint?
public init(element: PureXML.Model.Element, type: ElementType) {
public init(element: PureXML.Model.Element, type: ElementType, valueConstraint: ValueConstraint? = nil) {
self.element = element
self.type = type
self.valueConstraint = valueConstraint
}
}
}
@ -23,7 +27,25 @@ public extension PureXML.Schema.ComplexValidator {
/// rather than a bare method call.
static var shallowValidity: PureXML.Validation.Validation<PureXML.Schema.ResolvedElement, PureXML.Schema.ComplexValidator> {
.init(description: "Each streamed element is valid against its declared XSD type") { context in
context.document.validateShallow(context.subject.element, as: context.subject.type, at: context.codingPath)
context.document.validateShallow(
context.subject.element,
as: context.subject.type,
valueConstraint: context.subject.valueConstraint,
at: context.codingPath,
)
}
}
/// The matched particle's `default`/`fixed` value constraint for a child of
/// `parent`'s content model, keyed by namespaced identity (so same-local-name
/// elements in different types do not collide as in the flat `elementConstraints`).
func childValueConstraint(of parent: PureXML.Schema.ElementType, child name: PureXML.Model.QualifiedName) -> PureXML.Schema.ValueConstraint? {
guard case let .complex(complex) = parent else { return nil }
switch complex.content {
case let .elementOnly(particle), let .mixed(particle):
return Self.elementValueConstraints(in: particle.term)[Self.key(name)]
case .empty, .simpleContent:
return nil
}
}
@ -135,12 +157,13 @@ public extension PureXML.Schema.ComplexValidator {
func validateShallow(
_ element: PureXML.Model.Element,
as type: PureXML.Schema.ElementType,
valueConstraint: PureXML.Schema.ValueConstraint? = nil,
at path: [PureXML.Validation.PathKey] = [],
) -> [PureXML.Validation.ValidationError] {
var errors: [XSDFailure] = []
switch type {
case let .simple(simple):
validateSimpleElement(element, simple, at: path, into: &errors)
validateSimpleElement(element, simple, valueConstraint: valueConstraint, at: path, into: &errors)
case let .complex(complex):
validateAttributes(element, complex, at: path, into: &errors)
if let nilErrors = nilErrors(element, at: path) { return errors + nilErrors }
@ -155,13 +178,19 @@ public extension PureXML.Schema.ComplexValidator {
case let .circular(name):
return [XSDFailure(reason: "circular type reference '\(name)'", at: path)]
case let .resolved(resolved):
return validateShallow(element, as: resolved, at: path)
return validateShallow(element, as: resolved, valueConstraint: valueConstraint, at: path)
}
}
return errors
}
private func validateSimpleElement(_ element: PureXML.Model.Element, _ simple: PureXML.Schema.SimpleType, at path: XSDPath, into errors: inout [XSDFailure]) {
private func validateSimpleElement(
_ element: PureXML.Model.Element,
_ simple: PureXML.Schema.SimpleType,
valueConstraint: PureXML.Schema.ValueConstraint? = nil,
at path: XSDPath,
into errors: inout [XSDFailure],
) {
if let nilErrors = nilErrors(element, at: path) {
errors += nilErrors
return
@ -169,12 +198,18 @@ public extension PureXML.Schema.ComplexValidator {
if !element.children.compactMap(\.element).isEmpty {
errors.append(XSDFailure(reason: "'\(element.name.localName)' must not have children", at: path))
}
// An empty element takes its `default`/`fixed` value; that value (not the
// empty string) is what must be valid against the type, matching the tree
// path's validateChild.
let text = Self.rawTextContent(element)
if let error = simple.validate(text) {
if let error = simple.validate(effectiveSimpleText(for: element, particleConstraint: valueConstraint)) {
errors.append(XSDFailure(reason: "'\(element.name.localName)': \(error)", at: path))
}
recordIDs(simple, value: text, at: path)
errors += elementFixedErrors(element, at: path)
// Compare a present fixed value in the type's value space (e.g. "05" == "5"
// for xs:int) and against the matched particle's fixed value, as the tree
// path's validateChild does, not a raw string compare.
errors += elementFixedErrors(element, valueType: simple, particleFixed: valueConstraint?.fixedValue, at: path)
}
private func shallowContent(_ element: PureXML.Model.Element, _ content: PureXML.Schema.ContentType, at path: XSDPath, into errors: inout [XSDFailure]) {

View File

@ -93,8 +93,13 @@ public extension PureXML.Validation {
guard let frame = stack.popLast() else { return }
if let effective = frame.effective {
// Apply the streaming content check as a composable Validation value
// (the OpenAPIKit idiom), not a bare method call.
let subject = PureXML.Schema.ResolvedElement(element: frame.synthesized(), type: effective)
// (the OpenAPIKit idiom), not a bare method call. The parent (now the
// stack top) supplies the matched particle's value constraint, so an
// empty child validates its `default`/`fixed` value as the tree does.
let valueConstraint = stack.last?.effective.flatMap {
validator.childValueConstraint(of: $0, child: frame.name)
}
let subject = PureXML.Schema.ResolvedElement(element: frame.synthesized(), type: effective, valueConstraint: valueConstraint)
collected += PureXML.Schema.ComplexValidator.shallowValidity.apply(to: subject, at: frame.path, in: validator)
}
}

View File

@ -0,0 +1,102 @@
import Testing
@testable import PureXML
/// An empty element with a `default`/`fixed` value constraint takes that value as
/// its content, and that value (not the empty string) must satisfy the element's
/// simple type (cvc-elt.5.1.2 / cvc-elt.5.2.2.2). The streaming validator must apply
/// the constraint the same way the tree path does (#200), so an empty element with a
/// valid default is not falsely rejected. Each case asserts streaming agrees with
/// the tree-path oracle.
@Suite("XSD streaming default/fixed value constraints (#200)")
struct SchemaStreamingValueConstraintTests {
private func schema(_ constraint: String) -> String {
"""
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:t" xmlns:t="urn:t">
<xs:complexType name="T"><xs:sequence>
<xs:element name="a" type="xs:int" \(constraint)/>
</xs:sequence></xs:complexType>
<xs:element name="r" type="t:T"/>
</xs:schema>
"""
}
private func agreement(_ schemaSource: String, _ instance: String) throws -> [String] {
let document = try PureXML.Schema.Document(schemaSource)
let tree = try document.validate(instance).map(\.reason).sorted()
let streamed = try document.validate(streaming: instance).map(\.reason).sorted()
#expect(tree == streamed, "streaming disagreed with tree:\n tree: \(tree)\n stream: \(streamed)")
return streamed
}
@Test("an empty element with a valid default is accepted on both paths")
func test_emptyTakesDefault() throws {
let instance = "<t:r xmlns:t=\"urn:t\"><a></a></t:r>"
#expect(try agreement(schema("default=\"5\""), instance).isEmpty)
}
@Test("an empty element with a valid fixed value is accepted on both paths")
func test_emptyTakesFixed() throws {
let instance = "<t:r xmlns:t=\"urn:t\"><a></a></t:r>"
#expect(try agreement(schema("fixed=\"5\""), instance).isEmpty)
}
@Test("a present non-empty value is still validated against the type on both paths")
func test_presentValueStillValidated() throws {
let instance = "<t:r xmlns:t=\"urn:t\"><a>notanint</a></t:r>"
#expect(try !agreement(schema("default=\"5\""), instance).isEmpty)
}
@Test("same-local-name elements with different defaults use the matched particle, not the flat map")
func test_collidingLocalNameConstraints() throws {
// Two complex types both declare a local element 'v' with a different default;
// B is declared after A, so the by-local-name elementConstraints map keeps B's
// (type-invalid for A). Streaming must use A's matched-particle default "5" for
// an empty <v/> under root (type A), not the colliding "hello".
let collisionSchema = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:t" xmlns:t="urn:t">
<xs:element name="root" type="t:A"/>
<xs:complexType name="A"><xs:sequence>
<xs:element name="v" type="xs:int" default="5"/>
</xs:sequence></xs:complexType>
<xs:element name="other" type="t:B"/>
<xs:complexType name="B"><xs:sequence>
<xs:element name="v" type="xs:string" default="hello"/>
</xs:sequence></xs:complexType>
</xs:schema>
"""
let instance = "<t:root xmlns:t=\"urn:t\"><v/></t:root>"
#expect(try agreement(collisionSchema, instance).isEmpty)
}
@Test("a present fixed value is compared in the type's value space, not raw string")
func test_fixedValueSpaceComparison() throws {
// "05" equals fixed "5" in the xs:int value space; both paths accept.
let instance = "<t:r xmlns:t=\"urn:t\"><a>05</a></t:r>"
#expect(try agreement(schema("fixed=\"5\""), instance).isEmpty)
}
@Test("a present fixed value that differs in value space is rejected on both paths")
func test_fixedValueMismatchRejected() throws {
let instance = "<t:r xmlns:t=\"urn:t\"><a>6</a></t:r>"
#expect(try !agreement(schema("fixed=\"5\""), instance).isEmpty)
}
@Test("a present fixed value under a same-local-name collision uses the matched particle")
func test_fixedCollisionPresentContent() throws {
let collisionSchema = """
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:t" xmlns:t="urn:t">
<xs:element name="root" type="t:A"/>
<xs:complexType name="A"><xs:sequence>
<xs:element name="v" type="xs:int" fixed="5"/>
</xs:sequence></xs:complexType>
<xs:element name="other" type="t:B"/>
<xs:complexType name="B"><xs:sequence>
<xs:element name="v" type="xs:string" fixed="hello"/>
</xs:sequence></xs:complexType>
</xs:schema>
"""
// <v>5</v> under root (type A) matches A's fixed "5", not B's colliding "hello".
let instance = "<t:root xmlns:t=\"urn:t\"><v>5</v></t:root>"
#expect(try agreement(collisionSchema, instance).isEmpty)
}
}