From aeff7869f9d1fe3fea0f3422d6eab5ea151171ae Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Sat, 20 Jun 2026 17:18:49 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 9 ++ Sources/Schema/ComplexValidator.swift | 16 ++- Sources/Schema/ComplexValidatorHelpers.swift | 23 ++++ .../Schema/ComplexValidatorStreaming.swift | 49 +++++++-- Sources/Schema/StreamingXSDValidator.swift | 9 +- .../SchemaStreamingValueConstraintTests.swift | 102 ++++++++++++++++++ 6 files changed, 196 insertions(+), 12 deletions(-) create mode 100644 Tests/SchemaStreamingValueConstraintTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4de19e2..c1d3316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/Sources/Schema/ComplexValidator.swift b/Sources/Schema/ComplexValidator.swift index 289d7dc..13750aa 100644 --- a/Sources/Schema/ComplexValidator.swift +++ b/Sources/Schema/ComplexValidator.swift @@ -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) diff --git a/Sources/Schema/ComplexValidatorHelpers.swift b/Sources/Schema/ComplexValidatorHelpers.swift index 5aad85a..6419c54 100644 --- a/Sources/Schema/ComplexValidatorHelpers.swift +++ b/Sources/Schema/ComplexValidatorHelpers.swift @@ -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)" } diff --git a/Sources/Schema/ComplexValidatorStreaming.swift b/Sources/Schema/ComplexValidatorStreaming.swift index f1c580a..be95c0a 100644 --- a/Sources/Schema/ComplexValidatorStreaming.swift +++ b/Sources/Schema/ComplexValidatorStreaming.swift @@ -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 { .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]) { diff --git a/Sources/Schema/StreamingXSDValidator.swift b/Sources/Schema/StreamingXSDValidator.swift index 3e9027a..1e03478 100644 --- a/Sources/Schema/StreamingXSDValidator.swift +++ b/Sources/Schema/StreamingXSDValidator.swift @@ -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) } } diff --git a/Tests/SchemaStreamingValueConstraintTests.swift b/Tests/SchemaStreamingValueConstraintTests.swift new file mode 100644 index 0000000..3e72375 --- /dev/null +++ b/Tests/SchemaStreamingValueConstraintTests.swift @@ -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 { + """ + + + + + + + """ + } + + 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 = "" + #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 = "" + #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 = "notanint" + #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 under root (type A), not the colliding "hello". + let collisionSchema = """ + + + + + + + + + + + """ + let instance = "" + #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 = "05" + #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 = "6" + #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 = """ + + + + + + + + + + + """ + // 5 under root (type A) matches A's fixed "5", not B's colliding "hello". + let instance = "5" + #expect(try agreement(collisionSchema, instance).isEmpty) + } +}