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.
This commit is contained in:
Mihaela Mihaljevic 2026-06-14 14:19:10 +02:00
parent 6a7fb1d594
commit 11c4eacdff
6 changed files with 105 additions and 2 deletions

View File

@ -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).

View File

@ -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<String>] = [
"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>) -> 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
}
}

View File

@ -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)

View File

@ -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("""
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
\(body)
</xs:schema>
""")
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(#"<xs:element name="e" type="xs:string" final="substitution"/>"#))
#expect(rejects(#"<xs:element name="e" type="xs:string" final="extension restriction substitution"/>"#))
// unknown token and wrong case.
#expect(rejects(#"<xs:element name="e" type="xs:string" final="foo"/>"#))
#expect(rejects(##"<xs:element name="e" type="xs:string" final="#All"/>"##))
#expect(rejects(#"<xs:element name="e" type="xs:string" final="Extension"/>"#))
// valid finals and a valid block with substitution.
#expect(!rejects(##"<xs:element name="e" type="xs:string" final="#all"/>"##))
#expect(!rejects(#"<xs:element name="e" type="xs:string" final="extension restriction"/>"#))
#expect(!rejects(#"<xs:element name="e" type="xs:string" block="substitution"/>"#))
}
@Test("simpleType final admits list/union/restriction, not extension")
func test_simpleType() {
#expect(rejects(#"<xs:simpleType name="t" final="extension"><xs:restriction base="xs:string"/></xs:simpleType>"#))
#expect(!rejects(#"<xs:simpleType name="t" final="list union restriction"><xs:restriction base="xs:string"/></xs:simpleType>"#))
#expect(!rejects(##"<xs:simpleType name="t" final="#all"><xs:restriction base="xs:string"/></xs:simpleType>"##))
}
@Test("complexType block/final admit only extension/restriction")
func test_complexType() {
#expect(rejects(#"<xs:complexType name="t" block="substitution"><xs:sequence/></xs:complexType>"#))
#expect(!rejects(#"<xs:complexType name="t" final="restriction"><xs:sequence/></xs:complexType>"#))
}
}

View File

@ -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

View File

@ -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 | |