feat(schema): reject ur-type widening in element restriction

Extend NameAndTypeOK: restricting a concrete-typed element to an ur-type
(xs:anyType / xs:anySimpleType / xs:anyAtomicType) is widening, hence an invalid
restriction. The element-vs-element check now rejects it before the atomic
derivation check.

The ur-type is recognised by a type reference whose key is anyType /
anySimpleType / anyAtomicType AND which is absent from the named-type table, so
a user type that merely shares the local name (in another namespace) is not
mistaken for the ur-type. The reverse (an ur-type base narrowed by the
restriction) stays valid.

XSTS: invalid schemas accepted 620 to 616, valid schemas rejected held at 72,
instance buckets unchanged.
This commit is contained in:
Mihaela Mihaljevic 2026-06-14 13:07:31 +02:00
parent 7a80410945
commit 711a50b8ea
5 changed files with 63 additions and 9 deletions

View File

@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Particle Valid (Restriction) now checks an element's type, not just its name (NameAndTypeOK, #158). When a restriction maps a same-named element onto a base element, the restricting type must derive from the base's; renaming an `xs:string` element to `xs:int` under restriction was accepted because only the names were compared. The check resolves both element types (following a type reference through the compiled type table) and, when both are atomic simple types, requires the restricting type's built-in base to derive from the base's; a complex, list, union, untyped, or unresolvable type is left alone, so no valid restriction is rejected. XSTS: invalid schemas accepted 621 to 620, no other bucket raised (valid schemas rejected held at 72).
- Particle Valid (Restriction) now checks an element's type, not just its name (NameAndTypeOK, #158). When a restriction maps a same-named element onto a base element, the restricting type must derive from the base's; renaming an `xs:string` element to `xs:int`, or widening a concrete-typed element to `xs:anyType`/`xs:anySimpleType`, under restriction was accepted because only the names were compared. The check resolves both element types (following a type reference through the compiled type table) and, when both are atomic simple types, requires the restricting type's built-in base to derive from the base's; widening to an ur-type over a concrete base is rejected outright. A complex, list, union, untyped, or unresolvable type is left alone, and a user type that merely shares the local name `anyType` (in another namespace) is not mistaken for the ur-type, so no valid restriction is rejected. XSTS: invalid schemas accepted 621 to 616, no other 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).
- A constraining facet must apply to its simple type's variety (XSD 1.0 Part 2, #158). A `list` may be constrained only by `length`/`minLength`/`maxLength`, `pattern`, `enumeration`, and `whiteSpace`; a `union` only by `pattern` and `enumeration`. A value-bound facet (`maxInclusive`, `totalDigits`, ...) constrains an atomic value space, so restricting a list (`list of integer`) by `maxInclusive` was wrongly accepted. The base's variety is resolved from the raw tree (the check runs before the types are compiled), namespace-aware so a user type that shares a built-in's local name does not flip the variety: a base in the XSD namespace is a built-in (only the list datatypes are list-variety), a base in the schema's own target namespace resolves to a declared global, and an external or unresolvable base is skipped, so no valid restriction is rejected. XSTS: invalid schemas accepted 664 to 638, no other bucket raised (valid schemas rejected held at 72).
- A complexType's shorthand content (without `simpleContent`/`complexContent`) is now checked for order, not just cardinality (#158). Its model is `(annotation?, (group | all | choice | sequence)?, (attribute | attributeGroup)*, anyAttribute?)`: an optional model group, then attributes, then an optional `anyAttribute`. The existing content-shape check caught the wrong number of model groups and the exclusivity of a content spec, but not the order, so a model group after an attribute, an attribute after `anyAttribute`, or a second `anyAttribute` (the ctB family) was accepted. The complexType's direct children, excluding `annotation` and the content specs, are matched against the same ordered slot model used for a `complexContent` derivation. XSTS: invalid schemas accepted 687 to 664, no other bucket raised (valid schemas rejected held at 72).

View File

@ -115,19 +115,44 @@ extension PureXML.Schema {
return base.indices.allSatisfy { used[$0] || emptiable(base[$0]) }
}
/// A conservative slice of NameAndTypeOK: when a restricting element and the
/// base element it maps onto both resolve to atomic simple types, the
/// restricting type's built-in base must derive from the base's, so renaming
/// an `xs:string` element to `xs:int` under restriction is rejected. Any other
/// pairing (complex content, a list/union variety, an unresolvable or ur-type
/// reference) is permitted here, so no valid restriction is rejected.
/// A conservative slice of NameAndTypeOK. Two checks, both over-rejection-safe:
/// restricting an element to an ur-type (`anyType`/`anySimpleType`) when the
/// base has a concrete type is always widening, hence invalid; and when both
/// elements resolve to atomic simple types, the restricting type's built-in
/// base must derive from the base's (so an `xs:string` element renamed to
/// `xs:int` is rejected). Any other pairing (complex content, a list/union
/// variety, a named user type whose derivation the flattened particle model
/// does not record) is permitted, so no valid restriction is rejected.
private static func elementTypeRestrictionOK(_ restricted: ElementType?, _ base: ElementType?, _ types: [String: ElementType]) -> Bool {
if isUrTypeReference(restricted, types), isConcreteType(base) { return false }
guard let restrictedSimple = resolvedAtomic(restricted, types),
let baseSimple = resolvedAtomic(base, types)
else { return true }
return restrictedSimple.base.derives(from: baseSimple.base)
}
private static let urTypeNames: Set<String> = ["anyType", "anySimpleType", "anyAtomicType"]
/// Whether an element type is a reference to one of the XSD ur-types (the
/// widest types, which nothing may be restricted *to* from a narrower base).
/// A genuine ur-type is not in the named-type table; a user type that merely
/// shares the local name (in another namespace) is, so checking absence keeps
/// the namespace-blind reference key from misclassifying it.
private static func isUrTypeReference(_ type: ElementType?, _ types: [String: ElementType]) -> Bool {
if case let .typeReference(key) = type { return urTypeNames.contains(key) && types[key] == nil }
return false
}
/// Whether an element type names a concrete type (a built-in or a non-ur user
/// type), as opposed to an ur-type or an absent/unknown type.
private static func isConcreteType(_ type: ElementType?) -> Bool {
switch type {
case .simple: true
case let .typeReference(key): !urTypeNames.contains(key)
case .complex, .none: false
}
}
/// The atomic ``SimpleType`` an element type resolves to (following
/// ``ElementType/typeReference`` through `types`), or nil for a complex,
/// list/union, or unresolvable type.

View File

@ -145,4 +145,33 @@ struct XSDParticleRestrictionTests {
restriction: "<xs:sequence><xs:element name=\"a\" type=\"xs:string\"/></xs:sequence>",
))
}
@Test("NameAndTypeOK: widening a concrete-typed element to the ur-type is rejected")
func test_elementUrTypeWidening() {
// Restricting a string element to anyType/anySimpleType is widening, invalid.
#expect(!compiles(
base: "<xs:choice><xs:element name=\"c1\" type=\"xs:string\"/><xs:element name=\"c2\"/></xs:choice>",
restriction: "<xs:choice><xs:element name=\"c1\" type=\"xs:anyType\"/><xs:element name=\"c2\"/></xs:choice>",
))
#expect(!compiles(
base: "<xs:choice><xs:element name=\"c1\" type=\"xs:string\"/><xs:element name=\"c2\"/></xs:choice>",
restriction: "<xs:choice><xs:element name=\"c1\" type=\"xs:anySimpleType\"/><xs:element name=\"c2\"/></xs:choice>",
))
// The reverse (base is the ur-type, restriction narrows it) is valid.
#expect(compiles(
base: "<xs:choice><xs:element name=\"c1\" type=\"xs:anyType\"/><xs:element name=\"c2\"/></xs:choice>",
restriction: "<xs:choice><xs:element name=\"c1\" type=\"xs:string\"/><xs:element name=\"c2\"/></xs:choice>",
))
// A user type whose local name is "anyType" (not the XSD ur-type) must not be
// mistaken for it: this is a valid same-type restriction.
#expect((try? PureXML.Schema.Document("""
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="urn:t" xmlns:t="urn:t">
<xs:simpleType name="anyType"><xs:restriction base="xs:string"/></xs:simpleType>
<xs:complexType name="B"><xs:choice><xs:element name="c1" type="t:anyType"/></xs:choice></xs:complexType>
<xs:complexType name="R"><xs:complexContent><xs:restriction base="t:B">
<xs:choice><xs:element name="c1" type="t:anyType"/></xs:choice>
</xs:restriction></xs:complexContent></xs:complexType>
</xs:schema>
""")) != nil)
}
}

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 = 620
private let knownSchemaInvalidAccepted = 616
private let knownInstanceValidRejected = 214
private let knownInstanceInvalidAccepted = 165

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 -> 620** | 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 -> 616** | 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 | |