From 91894d57596d97fa976705fe5b2a463cc7e97edb Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Thu, 18 Jun 2026 15:01:04 +0200 Subject: [PATCH] fix(schema): reject two ID-typed attributes on one element at instance time; invalid-instances-accepted 45 to 43 (#147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XSD 1.0 §3.4.6 (cvc-complex-type) allows an element at most one attribute whose type is xs:ID. A complex type that DECLARES two ID-typed attribute uses is already a compile-time schema error, but two ID-typed attributes can also reach an element through an xs:anyAttribute wildcard that admits two ID-typed global attributes (attZ014), which was accepted. validateAttributes now counts the present attributes whose effective type is xs:ID, taking the type from the declared use an attribute matches or the global declaration a matching wildcard admits (so a restriction of xs:ID counts), and reports an error when more than one is present. xs:IDREF/xs:ENTITY and a single ID attribute are unaffected, and a valid instance never carries two, so the check cannot over-reject. invalid-instances-accepted 45 to 43, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved. --- CHANGELOG.md | 2 + .../Schema/ComplexValidatorAttributes.swift | 36 +++++++++++++++++ Tests/SchemaTwoIDAttributesTests.swift | 39 +++++++++++++++++++ Tests/XSTSSuiteTests.swift | 4 +- 4 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 Tests/SchemaTwoIDAttributesTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index febcfa5..0feb7bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- XSTS invalid-instances-accepted (#147): an element may now carry at most one attribute whose type is `xs:ID` (XSD 1.0 §3.4.6). Two ID-typed attributes admitted through an `xs:anyAttribute` wildcard (the schema itself stays valid, as a type with two declared ID attribute uses is already a compile error) are rejected at instance time. The count covers both a matched declared use's type and the global declaration a matching wildcard admits, including a restriction of `xs:ID`; `xs:IDREF`/`xs:ENTITY` and a lone ID attribute are unaffected. invalid-instances-accepted 45 → 43, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved. + - XSTS invalid-instances-accepted (#146, #147): an identity-constraint (`unique`/`key`/`keyref`) field that selects an absent attribute or an empty element whose declaration carries a `default`/`fixed` now takes that value as its identity-tuple component, so two members can collide on the defaulted value (idG011/idG012 default, idF016/idF017 fixed). The schema's `default`/`fixed` value constraints on identity-field targets are gathered at compile time, keyed the same way as the field types, and threaded through to the identity validator. The substitution is exact about absent versus present-empty: an `@attr` field is defaulted only when the attribute is ABSENT (empty node-set), never when it is present with value `""`; a `.` element field is defaulted only when the selected element has no character content. Applying a default only adds an identity value where the schema declares one, so it can create a genuine duplicate-key detection but never reject a valid instance. invalid-instances-accepted 50 → 45, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved. - XSTS invalid-instances-accepted (#147): a `block` on a substitution-group head's TYPE, not only on the head element, now bars a member from substituting. The substitution-group filter drops a member whose type reaches the head's type by a blocked derivation method, using the union of the head element's `{disallowed substitutions}` and the head type's `{prohibited substitutions}`; previously only the head element's own `block` was consulted, so a `block="restriction"` on the head's complex type let a restriction-derived member substitute. The element-level `block="substitution"` outright bar is unchanged, and a member derived by a method the block does not list still substitutes. invalid-instances-accepted 53 → 50, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved. diff --git a/Sources/Schema/ComplexValidatorAttributes.swift b/Sources/Schema/ComplexValidatorAttributes.swift index 9c709d2..9c408db 100644 --- a/Sources/Schema/ComplexValidatorAttributes.swift +++ b/Sources/Schema/ComplexValidatorAttributes.swift @@ -21,6 +21,42 @@ extension PureXML.Schema.ComplexValidator { } } validateWildcardAttributes(present, element: element, type: type, at: path, into: &errors) + if idTypedAttributeCount(present, type, on: element) > 1 { + errors.append(PureXML.Validation.ValidationError( + reason: "an element may carry at most one attribute of type ID", + at: path, + )) + } + } + + /// The number of `present` attributes whose effective type is `xs:ID` (an + /// element may carry at most one; XSD 1.0 §3.4.6, cvc-complex-type). An + /// attribute's type is its declared use's type when it matches one, otherwise + /// the global declaration a matching wildcard admits. + private func idTypedAttributeCount( + _ present: [PureXML.Model.Attribute], + _ type: PureXML.Schema.ComplexType, + on element: PureXML.Model.Element, + ) -> Int { + present.reduce(into: 0) { count, attribute in + if isIDTyped(attribute, type, on: element) { count += 1 } + } + } + + /// Whether `attribute`'s effective type is `xs:ID`: the type of the declared + /// use it matches, or the global declaration a matching wildcard admits. + private func isIDTyped( + _ attribute: PureXML.Model.Attribute, + _ type: PureXML.Schema.ComplexType, + on element: PureXML.Model.Element, + ) -> Bool { + if let use = type.attributes.first(where: { Self.attributeMatches(attribute.name, declared: $0.name, use: $0, on: element.name) }) { + return use.type.isID + } + guard let wildcard = type.attributeWildcard, wildcard.admits(attribute.name), + let use = globalAttributeUse(for: attribute.name) + else { return false } + return use.type.isID } /// Whether an instance attribute satisfies a declared use. Chameleon-included diff --git a/Tests/SchemaTwoIDAttributesTests.swift b/Tests/SchemaTwoIDAttributesTests.swift new file mode 100644 index 0000000..8f29ad1 --- /dev/null +++ b/Tests/SchemaTwoIDAttributesTests.swift @@ -0,0 +1,39 @@ +@testable import PureXML +import Testing + +/// An element may carry at most one attribute whose type is `xs:ID` (XSD 1.0 +/// §3.4.6, cvc-complex-type). Two ID-typed attributes on one element, whether +/// declared or admitted through an attribute wildcard, are invalid; one is fine. +@Suite("at most one ID attribute per element") +struct SchemaTwoIDAttributesTests { + /// The W3C attZ014 shape: the two ID-typed attributes are not declared on the + /// type (which would already be a schema error) but enter the instance through + /// an `anyAttribute` wildcard that admits two ID-typed global attributes, one + /// of them a restriction of `xs:ID`. + @Test("two ID-typed attributes admitted via a wildcard are rejected") + func test_twoWildcardIDsRejected() throws { + let schema = try PureXML.Schema.Document(""" + + + + + + + """) + #expect(try !schema.validate(#""#).isEmpty) + } + + @Test("a single ID attribute plus non-ID attributes is accepted") + func test_singleIDAccepted() throws { + let schema = try PureXML.Schema.Document(""" + + + + + + + + """) + #expect(try schema.validate(#""#).isEmpty) + } +} diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 0c6d423..8e0231b 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -150,13 +150,13 @@ struct XSTSSuiteTests { private let knownSchemaValidRejected = 0 private let knownSchemaInvalidAccepted = 69 private let knownInstanceValidRejected = 0 - /// Instance bucket (133 -> 45), per-step deltas in CHANGELOG.md: xsi:type must derive from the + /// Instance bucket (133 -> 43), per-step deltas in CHANGELOG.md: xsi:type must derive from the /// declared type (complex, then atomic and list/union, resolved through the element-ref chain); /// block/blockDefault (incl. "substitution" and an untyped head) bar substitutions after the /// derivation backbone was re-keyed by namespace; ur-type `xsd:anyType` wildcards are `lax`; /// identity fields compare in the decimal value space and take a declared default/fixed when /// absent or empty; a complex type's attribute wildcard is the INTERSECTION of its sources. - private let knownInstanceInvalidAccepted = 45 + private let knownInstanceInvalidAccepted = 43 @Test("Every XSTS case behaves: compile, reject, validate, invalidate") func test_suite() throws {