From b80e31d562b56439741f9cfadce0b3faecdcaaad Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 15 Jun 2026 16:09:02 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 4 ++ .../Schema/SchemaAttributeRestriction.swift | 52 ++++++++++++++++ Sources/Schema/SchemaIDAttribute.swift | 1 + Tests/SchemaAttributeRestrictionTests.swift | 60 +++++++++++++++++++ Tests/XSTSSuiteTests.swift | 2 +- docs/schema-validity-burndown.md | 2 +- 6 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 Sources/Schema/SchemaAttributeRestriction.swift create mode 100644 Tests/SchemaAttributeRestrictionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b8a476d..e39b92b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/Schema/SchemaAttributeRestriction.swift b/Sources/Schema/SchemaAttributeRestriction.swift new file mode 100644 index 0000000..c702d98 --- /dev/null +++ b/Sources/Schema/SchemaAttributeRestriction.swift @@ -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 + } +} diff --git a/Sources/Schema/SchemaIDAttribute.swift b/Sources/Schema/SchemaIDAttribute.swift index 9362b7b..26ef0c3 100644 --- a/Sources/Schema/SchemaIDAttribute.swift +++ b/Sources/Schema/SchemaIDAttribute.swift @@ -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) } } diff --git a/Tests/SchemaAttributeRestrictionTests.swift b/Tests/SchemaAttributeRestrictionTests.swift new file mode 100644 index 0000000..8086503 --- /dev/null +++ b/Tests/SchemaAttributeRestrictionTests.swift @@ -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("\(body)")) != nil + } + + private func derive(base: String, restricted: String) -> String { + "\(base)" + + "" + + "\(restricted)" + } + + @Test("A restriction may not relax a required attribute") + func test_requiredRelaxationRejected() { + let req = "" + // required -> optional (explicit) + #expect(!compiles(derive(base: req, restricted: ""))) + // required -> optional (default use) + #expect(!compiles(derive(base: req, restricted: ""))) + // required -> prohibited + #expect(!compiles(derive(base: req, restricted: ""))) + } + + @Test("A faithful or tightening attribute restriction compiles") + func test_validRestrictionsAccepted() { + // required kept required + let req = "" + #expect(compiles(derive(base: req, restricted: req))) + // fixed kept identical + let fixed = "" + #expect(compiles(derive(base: fixed, restricted: ""))) + // optional base may be made prohibited or stay optional + let opt = "" + #expect(compiles(derive(base: opt, restricted: ""))) + // 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: ""))) + } + + /// 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 = "" + + "" + let derived = "" + + "" + // Same list value, different whitespace: valid, must compile. + #expect(compiles(listBase + derived)) + } +} diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 8e58387..2769056 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 = 1 - private let knownSchemaInvalidAccepted = 394 + private let knownSchemaInvalidAccepted = 390 private let knownInstanceValidRejected = 180 private let knownInstanceInvalidAccepted = 158 diff --git a/docs/schema-validity-burndown.md b/docs/schema-validity-burndown.md index 70bd6cc..2bdeb55 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 -> 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 `` restriction base preserves list/union variety, so a `length` facet counts items and two more invalid instances are caught) |