From 11c4eacdff48ead779684dcc33f2012da3f6d134 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Sun, 14 Jun 2026 14:19:10 +0200 Subject: [PATCH] feat(schema): validate final/block derivation-control value space The final and block attributes are #all or a whitespace list of method tokens, but the admitted tokens depend on the component: element final is extension/restriction, element block adds substitution, complexType final/block are extension/restriction, simpleType final is list/union/restriction. The tokens were parsed leniently (unknown ignored, substitution admitted everywhere), so final="substitution" on an element, a misspelled or miscased token, or #all mixed with other tokens was accepted. The list is validated against the component's exact set; finalDefault/ blockDefault (the wider schema-level value space) and foreign attributes are left alone. XSTS: invalid schemas accepted 580 to 537, valid schemas rejected held at 72, instance buckets unchanged. --- CHANGELOG.md | 1 + Sources/Schema/SchemaDerivationControl.swift | 51 ++++++++++++++++++++ Sources/Schema/SchemaStructure.swift | 1 + Tests/SchemaDerivationControlTests.swift | 50 +++++++++++++++++++ Tests/XSTSSuiteTests.swift | 2 +- docs/schema-validity-burndown.md | 2 +- 6 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 Sources/Schema/SchemaDerivationControl.swift create mode 100644 Tests/SchemaDerivationControlTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 655d9e8..a8ade95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- The `final` and `block` derivation-control attributes are now checked against their value space (#158). Each is `#all` or a whitespace list of method tokens, but the admitted tokens depend on the component: an element's `final` is `extension`/`restriction` and its `block` adds `substitution`; a complexType's `final`/`block` are `extension`/`restriction`; a simpleType's `final` is `list`/`union`/`restriction`. The tokens were parsed leniently (unknown tokens ignored, `substitution` admitted everywhere), so `final="substitution"` on an element, a misspelled or miscased token, or `#all` mixed with other tokens was accepted (the elemF/st_final families). The list is now validated against the component's exact set; `finalDefault`/`blockDefault` (the wider schema-level value space) and foreign attributes are left alone. XSTS: invalid schemas accepted 580 to 537, no other bucket raised (valid schemas rejected held at 72). - A `simpleContent` derivation's content is now checked for order and disallowed children (#158). A `restriction` is `(annotation?, (simpleType?, facet*), (attribute | attributeGroup)*, anyAttribute?)` and an `extension` is `(annotation?, (attribute | attributeGroup)*, anyAttribute?)`: simpleContent has no model group, an extension has no facets or `simpleType`, and the attributes precede a single trailing `anyAttribute`. The membership table admitted the names but not the model, so a model group inside simpleContent, a facet in an extension, a facet before its `simpleType`, an attribute after `anyAttribute`, or a second `anyAttribute` (the ctD family) was accepted. The derivation's children are matched against the context's ordered slot model, reusing the slot walk built for complexContent. XSTS: invalid schemas accepted 594 to 580, no other bucket raised (valid schemas rejected held at 72). - Particle Valid (Restriction) now checks an element's type by derivation, not just its name (NameAndTypeOK, #158). When a restriction maps a same-named element onto a base element, the restricting type must be, or derive by restriction from, the base element's type; renaming `xs:string` to `xs:int`, widening a concrete type to `xs:anyType`, or naming an unrelated type (a different user type, a wider ancestor, an absent/`anyType` type over a concrete base) was accepted because only the names were compared. Each element particle now carries its type's derivation identity (the resolved type name, `anyType` for an absent type, or nil for an inline anonymous type), named `simpleType` restriction derivations are recorded alongside complex ones, and the check walks the user restriction chain and then the built-in lattice to decide derivation by name; an ur-type base, a union base (membership is not a restriction chain), and an inline anonymous type are permitted, so no valid restriction is rejected. XSTS: invalid schemas accepted 621 to 594, invalid instances accepted 165 to 160 (more invalid schemas rejected up front), no bucket raised (valid schemas rejected held at 72). - An element/attribute `default`/`fixed` value must be a valid value of its `type` (Attribute/Element Locally Valid, XSD 1.0, #158). A `fixed="abc"` on an `xs:integer` or a `default="1 2"` on an `xs:int` was accepted because the value constraint was never checked at compile time. The value is now validated against its type when the type resolves to a built-in datatype (namespace-aware: only a `type` in the XSD namespace), normalising whitespace per the type as XSD requires (so `default=" 5 "` on an integer is valid); a named or inline user type is left to its own rules, so no valid value constraint is rejected. XSTS: invalid schemas accepted 638 to 621, no other bucket raised (valid schemas rejected held at 72). diff --git a/Sources/Schema/SchemaDerivationControl.swift b/Sources/Schema/SchemaDerivationControl.swift new file mode 100644 index 0000000..c984b7a --- /dev/null +++ b/Sources/Schema/SchemaDerivationControl.swift @@ -0,0 +1,51 @@ +extension PureXML.Schema.XSDParser { + /// Value-space validity of the `final` and `block` derivation-control + /// attributes (XSD 1.0 Structures). Each is `#all` or a whitespace-separated + /// list of method tokens, but the admitted tokens depend on the component: + /// + /// - element `final`: `extension`, `restriction` + /// - element `block`: `extension`, `restriction`, `substitution` + /// - complexType `final`/`block`: `extension`, `restriction` + /// - simpleType `final`: `list`, `union`, `restriction` + /// + /// `methodSet` parses these leniently (ignoring unknown tokens and admitting + /// `substitution` everywhere), so `final="substitution"` on an element, a + /// misspelled or miscased token (`foo`, `#All`, `Extension`), or `#all` mixed + /// with other tokens was accepted (the elemF / st_final families). The token + /// list is now checked against the component's exact set. + static func derivationControlErrors(_ node: XSDTree, local: String) -> [String] { + var errors: [String] = [] + for attribute in ["final", "block"] { + guard let allowed = derivationControlTokens["\(local)|\(attribute)"], + let raw = node.attributes.first(where: { $0.name.prefix == nil && $0.name.localName == attribute })?.value + else { continue } + if let reason = derivationListError(raw, allowed: allowed) { + errors.append("the '\(attribute)' value '\(raw)' on '\(local)' is not valid: \(reason)") + } + } + return errors + } + + /// The exact method tokens each component admits in `final`/`block`. + private static let derivationControlTokens: [String: Set] = [ + "element|final": ["extension", "restriction"], + "element|block": ["extension", "restriction", "substitution"], + "complexType|final": ["extension", "restriction"], + "complexType|block": ["extension", "restriction"], + "simpleType|final": ["list", "union", "restriction"], + ] + + /// The reason a `final`/`block` value is invalid, or nil. `#all` stands alone; + /// otherwise every whitespace-separated token must be in `allowed` (exact case), + /// and `#all` may not be mixed with other tokens. An empty value is valid (no + /// control). + private static func derivationListError(_ raw: String, allowed: Set) -> String? { + let tokens = raw.split(whereSeparator: \.isWhitespace).map(String.init) + if tokens == ["#all"] { return nil } + for token in tokens { + if token == "#all" { return "'#all' may not be combined with other tokens" } + if !allowed.contains(token) { return "'\(token)' is not one of \(allowed.sorted().joined(separator: ", "))" } + } + return nil + } +} diff --git a/Sources/Schema/SchemaStructure.swift b/Sources/Schema/SchemaStructure.swift index 19817a2..6b9f937 100644 --- a/Sources/Schema/SchemaStructure.swift +++ b/Sources/Schema/SchemaStructure.swift @@ -156,6 +156,7 @@ extension PureXML.Schema.XSDParser { if let allowed = allowedChildren[local] { errors += childErrors(local: local, children: names, allowed: allowed) } + errors += derivationControlErrors(node, local: local) switch local { case "group" where PureXML.Schema.XSDNode.attribute(node, "name") != nil: errors += namedGroupErrors(names) diff --git a/Tests/SchemaDerivationControlTests.swift b/Tests/SchemaDerivationControlTests.swift new file mode 100644 index 0000000..bdec5ce --- /dev/null +++ b/Tests/SchemaDerivationControlTests.swift @@ -0,0 +1,50 @@ +@testable import PureXML +import Testing + +/// Value-space validity of the `final`/`block` derivation-control attributes +/// (XSD 1.0): `#all` or a whitespace list of method tokens, with the admitted +/// tokens depending on the component (element/complexType/simpleType) and on +/// whether it is `final` or `block`. The elemF / st_final families. +@Suite("Derivation control (final/block) value space") +struct SchemaDerivationControlTests { + private func rejects(_ body: String) -> Bool { + do { + _ = try PureXML.Schema.Document(""" + + \(body) + + """) + return false + } catch { + return true + } + } + + @Test("element final admits only extension/restriction; block also substitution") + func test_element() { + // substitution is a block token, not a final token. + #expect(rejects(#""#)) + #expect(rejects(#""#)) + // unknown token and wrong case. + #expect(rejects(#""#)) + #expect(rejects(##""##)) + #expect(rejects(#""#)) + // valid finals and a valid block with substitution. + #expect(!rejects(##""##)) + #expect(!rejects(#""#)) + #expect(!rejects(#""#)) + } + + @Test("simpleType final admits list/union/restriction, not extension") + func test_simpleType() { + #expect(rejects(#""#)) + #expect(!rejects(#""#)) + #expect(!rejects(##""##)) + } + + @Test("complexType block/final admit only extension/restriction") + func test_complexType() { + #expect(rejects(#""#)) + #expect(!rejects(#""#)) + } +} diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 657a2a5..8c0fe64 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -29,7 +29,7 @@ struct XSTSSuiteTests { /// XSTS` (debug is far slower and the corpus is large). Per-case deviations /// are written to /tmp/xsts-failures.txt for the burn-down. private let knownSchemaValidRejected = 72 - private let knownSchemaInvalidAccepted = 580 + private let knownSchemaInvalidAccepted = 537 private let knownInstanceValidRejected = 214 private let knownInstanceInvalidAccepted = 160 diff --git a/docs/schema-validity-burndown.md b/docs/schema-validity-burndown.md index 656cdfe..477c0ae 100644 --- a/docs/schema-validity-burndown.md +++ b/docs/schema-validity-burndown.md @@ -10,7 +10,7 @@ irreducible tail. Do not deviate to new features while this is open. | Bucket | Count | Reading | |---|---|---| | valid-schemas-rejected | 72 | we rarely reject a good schema | -| **invalid-schemas-accepted** | **2461 -> 580** | we rarely catch a bad one (facet definition + applicability, default/fixed value validity, particle-restriction name+type, id, structural content-model, UPA determinism, complexContent + complexType content order, identity-constraint XPath-subset, and wildcard-namespace validity landed) | +| **invalid-schemas-accepted** | **2461 -> 537** | we rarely catch a bad one (facet definition + applicability, default/fixed value validity, particle-restriction name+type, id, structural content-model, UPA determinism, complexContent + complexType content order, identity-constraint XPath-subset, and wildcard-namespace validity landed) | | valid-instances-rejected | 233 | (instance side, separately tracked) | | invalid-instances-accepted | 165 | |