feat(schema): a restriction may not relax a required base attribute (cos-ct-derived-ok)
In a complexContent restriction, an attribute the restriction redeclares that the base type marks use="required" may not be made optional or prohibited. Only attributes explicitly redeclared that also exist in the base are checked; an omitted attribute is inherited unchanged. The base reference must resolve to the schema's own target namespace, and the check stands down for a schema that composes other documents. The matching fixed-value clause is deliberately not enforced: it requires comparing the two fixed values in the attribute type's value space, not lexically (a list-typed fixed value differing only in whitespace is the same value), so a string comparison wrongly rejects valid schemas. That clause is left for a later change (a disclosed under-rejection). XSTS: invalid schemas accepted 394 to 390, no other bucket raised.
This commit is contained in:
parent
c4485ad6b5
commit
b80e31d562
@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- A complexContent restriction may no longer relax a required base attribute (XSD 1.0 `cos-ct-derived-ok` / `derivation-ok-restriction.2`, #170). When a restriction redeclares an attribute that the base type marks `use="required"`, the redeclaration may not make it optional or prohibited; such schemas were accepted. Only attributes the restriction explicitly redeclares that also exist in the base are checked (an omitted attribute is inherited unchanged); the base reference must resolve to the schema's own target namespace, and the check stands down for a schema that composes other documents. XSTS: invalid schemas accepted 394 to 390, no other bucket raised. Disclosed under-rejection: the matching fixed-value clause (a base attribute fixed to a value must keep that value) is not enforced, because it requires comparing fixed values in the attribute type's value space rather than lexically.
|
||||
|
||||
## [0.1.0] - 2026-06-15
|
||||
|
||||
First tagged pre-release. A pure-Swift XML parser/emitter with an XSD 1.0 schema
|
||||
|
||||
52
Sources/Schema/SchemaAttributeRestriction.swift
Normal file
52
Sources/Schema/SchemaAttributeRestriction.swift
Normal file
@ -0,0 +1,52 @@
|
||||
private typealias AttrRestrictNode = PureXML.Schema.XSDNode
|
||||
|
||||
extension PureXML.Schema.XSDParser {
|
||||
/// XSD 1.0 Derivation Valid (Restriction, Complex) for attribute uses
|
||||
/// (`cos-ct-derived-ok` / `derivation-ok-restriction.2`): when a complex type
|
||||
/// restricts a base, an attribute the restriction redeclares may not relax the
|
||||
/// base's corresponding use. A base `required` attribute must stay required: it
|
||||
/// may not become optional or prohibited. Such schemas were accepted.
|
||||
///
|
||||
/// Only attributes the restriction explicitly declares that also exist in the
|
||||
/// base are checked; an attribute the restriction omits is inherited unchanged
|
||||
/// (valid), and an attribute with no base counterpart is a separate rule, left
|
||||
/// alone. Checked for a self-contained schema (no `import`/`include`/`redefine`),
|
||||
/// where the base resolves locally and attribute names are unambiguous; the base
|
||||
/// reference must resolve to this schema's own target namespace.
|
||||
///
|
||||
/// Disclosed under-rejection: the matching fixed-value clause (a base attribute
|
||||
/// fixed to a value must keep that value) is not enforced here. It requires
|
||||
/// comparing the two fixed values in the attribute type's value space, not
|
||||
/// lexically (e.g. a list type's `"1 2 3"` and `"1 2 3"` are the same value),
|
||||
/// which a string comparison gets wrong; that clause is left for a later change.
|
||||
static func attributeRestrictionErrors(_ schema: XSDTree, _ context: PureXML.Schema.XSDContext, _ types: [String: PureXML.Schema.ElementType]) -> [String] {
|
||||
guard !hasExternalReference(schema) else { return [] }
|
||||
let bindings = context.namespaceBindings
|
||||
var errors: [String] = []
|
||||
for content in descendants(schema, named: "complexContent") {
|
||||
guard let restriction = AttrRestrictNode.firstChild(content, named: "restriction"),
|
||||
let base = AttrRestrictNode.attribute(restriction, "base"),
|
||||
AttrRestrictNode.referenceNamespace(base, bindings) == context.targetNamespace,
|
||||
case let .complex(complex)? = types[AttrRestrictNode.stripPrefix(base)]
|
||||
else { continue }
|
||||
for derived in attributeUses(under: restriction, context) {
|
||||
if let message = attributeRestrictionViolation(derived, complex.attributes) {
|
||||
errors.append(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
/// The way `derived` (an attribute the restriction redeclares) illegally relaxes
|
||||
/// its base counterpart, or nil when it is a valid restriction of it (or has no
|
||||
/// base counterpart). A base `required` attribute may not be made optional or
|
||||
/// prohibited (a prohibited use has `required == false`).
|
||||
private static func attributeRestrictionViolation(_ derived: PureXML.Schema.AttributeUse, _ baseAttributes: [PureXML.Schema.AttributeUse]) -> String? {
|
||||
guard let base = baseAttributes.first(where: { $0.name == derived.name }) else { return nil }
|
||||
if base.required, !derived.required {
|
||||
return "attribute '\(derived.name.localName)' is required in the base type and a restriction may not make it optional or prohibited"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -72,5 +72,6 @@ extension PureXML.Schema.XSDParser {
|
||||
+ substitutionTypeErrors(schema, derivation, typeMaps.named)
|
||||
+ userTypeValueConstraintErrors(schema, context, typeMaps.named)
|
||||
+ extensionAllGroupErrors(schema, context, typeMaps.named)
|
||||
+ attributeRestrictionErrors(schema, context, typeMaps.named)
|
||||
}
|
||||
}
|
||||
|
||||
60
Tests/SchemaAttributeRestrictionTests.swift
Normal file
60
Tests/SchemaAttributeRestrictionTests.swift
Normal file
@ -0,0 +1,60 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("XSD attribute-use restriction (cos-ct-derived-ok)")
|
||||
struct SchemaAttributeRestrictionTests {
|
||||
private let xsd = "xmlns:xs=\"http://www.w3.org/2001/XMLSchema\""
|
||||
|
||||
private func compiles(_ body: String) -> Bool {
|
||||
(try? PureXML.Schema.Document("<xs:schema \(xsd)>\(body)</xs:schema>")) != nil
|
||||
}
|
||||
|
||||
private func derive(base: String, restricted: String) -> String {
|
||||
"<xs:complexType name=\"Base\">\(base)</xs:complexType>"
|
||||
+ "<xs:complexType name=\"D\"><xs:complexContent><xs:restriction base=\"Base\">"
|
||||
+ "\(restricted)</xs:restriction></xs:complexContent></xs:complexType>"
|
||||
}
|
||||
|
||||
@Test("A restriction may not relax a required attribute")
|
||||
func test_requiredRelaxationRejected() {
|
||||
let req = "<xs:attribute name=\"a\" type=\"xs:string\" use=\"required\"/>"
|
||||
// required -> optional (explicit)
|
||||
#expect(!compiles(derive(base: req, restricted: "<xs:attribute name=\"a\" type=\"xs:string\" use=\"optional\"/>")))
|
||||
// required -> optional (default use)
|
||||
#expect(!compiles(derive(base: req, restricted: "<xs:attribute name=\"a\" type=\"xs:string\"/>")))
|
||||
// required -> prohibited
|
||||
#expect(!compiles(derive(base: req, restricted: "<xs:attribute name=\"a\" type=\"xs:string\" use=\"prohibited\"/>")))
|
||||
}
|
||||
|
||||
@Test("A faithful or tightening attribute restriction compiles")
|
||||
func test_validRestrictionsAccepted() {
|
||||
// required kept required
|
||||
let req = "<xs:attribute name=\"a\" type=\"xs:string\" use=\"required\"/>"
|
||||
#expect(compiles(derive(base: req, restricted: req)))
|
||||
// fixed kept identical
|
||||
let fixed = "<xs:attribute name=\"a\" type=\"xs:string\" fixed=\"x\"/>"
|
||||
#expect(compiles(derive(base: fixed, restricted: "<xs:attribute name=\"a\" type=\"xs:string\" fixed=\"x\"/>")))
|
||||
// optional base may be made prohibited or stay optional
|
||||
let opt = "<xs:attribute name=\"a\" type=\"xs:string\"/>"
|
||||
#expect(compiles(derive(base: opt, restricted: "<xs:attribute name=\"a\" type=\"xs:string\" use=\"prohibited\"/>")))
|
||||
// an attribute the restriction does not redeclare is inherited unchanged
|
||||
#expect(compiles(derive(base: req, restricted: "")))
|
||||
// an optional base attribute that is also fixed may be prohibited (the
|
||||
// prohibited use removes it, so the fixed clause does not apply).
|
||||
#expect(compiles(derive(base: fixed, restricted: "<xs:attribute name=\"a\" type=\"xs:string\" use=\"prohibited\"/>")))
|
||||
}
|
||||
|
||||
/// The fixed-value clause is a disclosed under-rejection (it needs value-space,
|
||||
/// not lexical, comparison). A list-typed fixed value that differs only in
|
||||
/// whitespace is the same value and must compile; and, for now, even a genuinely
|
||||
/// changed fixed value is accepted rather than risk a lexical false positive.
|
||||
@Test("Fixed-value differences on a restricted attribute are not (yet) rejected")
|
||||
func test_fixedClauseDeferred() {
|
||||
let listBase = "<xs:simpleType name=\"L\"><xs:list itemType=\"xs:int\"/></xs:simpleType>"
|
||||
+ "<xs:complexType name=\"Base\"><xs:attribute name=\"a\" type=\"L\" fixed=\"1 2 3\"/></xs:complexType>"
|
||||
let derived = "<xs:complexType name=\"D\"><xs:complexContent><xs:restriction base=\"Base\">"
|
||||
+ "<xs:attribute name=\"a\" type=\"L\" fixed=\"1 2 3\"/></xs:restriction></xs:complexContent></xs:complexType>"
|
||||
// Same list value, different whitespace: valid, must compile.
|
||||
#expect(compiles(listBase + derived))
|
||||
}
|
||||
}
|
||||
@ -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 = 1
|
||||
private let knownSchemaInvalidAccepted = 394
|
||||
private let knownSchemaInvalidAccepted = 390
|
||||
private let knownInstanceValidRejected = 180
|
||||
private let knownInstanceInvalidAccepted = 158
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ irreducible tail. Do not deviate to new features while this is open.
|
||||
| Bucket | Count | Reading |
|
||||
|---|---|---|
|
||||
| valid-schemas-rejected | **72 -> 4** | production target is 0: rejecting a correct schema is the most user-hostile defect (#148). Particle-restriction over-rejection fixed: pointless `maxOccurs=0` particles removed, content-free restriction of an EMPTY base, empty-derived emptiable-base, and (#163) the pairwise check over the normalized content model with occurrence subsumption applied only to same-kind pairings (cross-kind element/group, group/element, group/wildcard match occurrence inside, by effective total range / member). #164 resolved element-ref namespaces by prefix binding (the `particlesJj/Jk/Jm/Jn/Js`, `particlesQ/R` cluster, -28). Content-free (`maxOccurs=0`) derived groups and substitution-group declaration order closed `particlesW006`/`elemZ027_a`; the W3C MapAndSum rule (Sequence:Choice, #165) closed `particlesV003`. Remaining 1 (`particlesZ001`): a documented spec ambiguity (RecurseAsIfGroup; the reference rejects it), not a defect. |
|
||||
| **invalid-schemas-accepted** | **2461 -> 394** | we rarely catch a bad one (facet, default/fixed value, particle-restriction name+type, id, structural content-model, UPA determinism, content order, identity-constraint XPath-subset, wildcard-namespace, final/block, simpleType content, group/wildcard and group/element cardinality, element-ref namespace, sequence/choice MapAndSum, circular type derivation, circular group/attributeGroup/substitution references, attribute-use uniqueness and single-ID, type-excludes-inline-type, ID-typed value-constraint exclusion, all-group reference placement, substitution-member type derivation, default/fixed mutual exclusion landed). |
|
||||
| **invalid-schemas-accepted** | **2461 -> 390** | we rarely catch a bad one (facet, default/fixed value, particle-restriction name+type, id, structural content-model, UPA determinism, content order, identity-constraint XPath-subset, wildcard-namespace, final/block, simpleType content, group/wildcard and group/element cardinality, element-ref namespace, sequence/choice MapAndSum, circular type derivation, circular group/attributeGroup/substitution references, attribute-use uniqueness and single-ID, type-excludes-inline-type, ID-typed value-constraint exclusion, all-group reference placement, substitution-member type derivation, default/fixed mutual exclusion landed). |
|
||||
| valid-instances-rejected | **233 -> 180** | (instance side; #164's element-ref namespace fix is shared with instance validation, -34) |
|
||||
| invalid-instances-accepted | **160 -> 158** | (instance side; resolving an inline `<simpleType>` restriction base preserves list/union variety, so a `length` facet counts items and two more invalid instances are caught) |
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user