diff --git a/CHANGELOG.md b/CHANGELOG.md index 0feb7bf..31356ba 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): `use="prohibited"` on an attribute now excludes it from the complex type's effective `{attribute uses}` (XSD 1.0 §3.4.2) during instance validation, so a prohibited attribute is undeclared: its presence is rejected when no attribute wildcard admits it (attJ), and still admitted (and validated per the wildcard's `processContents`) when one does (attZ002). The prohibited declaration is kept in the type rather than dropped, so schema-validity checks still see it (the invalid-schemas-accepted count is unchanged). invalid-instances-accepted 43 → 39, no false positive (valid-instances-rejected and valid-schemas-rejected held at 0), no other bucket moved. + - 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. diff --git a/Sources/Schema/ComplexType.swift b/Sources/Schema/ComplexType.swift index d114aee..45e9b31 100644 --- a/Sources/Schema/ComplexType.swift +++ b/Sources/Schema/ComplexType.swift @@ -240,6 +240,10 @@ public extension PureXML.Schema { /// When true, an unprefixed instance attribute may match this use if the /// element is in the same namespace (chameleon include only). public var chameleonUnprefixed: Bool + /// `use="prohibited"`: the attribute is excluded from the type's effective + /// {attribute uses}, so it must not appear on an instance. The use is kept + /// (rather than dropped) so schema-validity checks still see the declaration. + public var prohibited: Bool public init( name: PureXML.Model.QualifiedName, @@ -247,12 +251,14 @@ public extension PureXML.Schema { required: Bool = false, valueConstraint: ValueConstraint? = nil, chameleonUnprefixed: Bool = false, + prohibited: Bool = false, ) { self.name = name self.type = type self.required = required self.valueConstraint = valueConstraint self.chameleonUnprefixed = chameleonUnprefixed + self.prohibited = prohibited } } diff --git a/Sources/Schema/ComplexValidatorAttributes.swift b/Sources/Schema/ComplexValidatorAttributes.swift index 9c408db..81c8088 100644 --- a/Sources/Schema/ComplexValidatorAttributes.swift +++ b/Sources/Schema/ComplexValidatorAttributes.swift @@ -6,7 +6,7 @@ extension PureXML.Schema.ComplexValidator { into errors: inout [PureXML.Validation.ValidationError], ) { let present = element.attributes.filter { !Self.isNamespaceDeclaration($0) && !Self.isSchemaInstanceAttribute($0) } - for use in type.attributes { + for use in type.attributes where !use.prohibited { let match = present.first { Self.attributeMatches($0.name, declared: use.name, use: use, on: element.name) } if let match { if let error = use.type.validate(match.value) { @@ -50,7 +50,7 @@ extension PureXML.Schema.ComplexValidator { _ 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) }) { + if let use = type.attributes.first(where: { !$0.prohibited && 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), @@ -82,7 +82,10 @@ extension PureXML.Schema.ComplexValidator { at path: [PureXML.Validation.PathKey], into errors: inout [PureXML.Validation.ValidationError], ) { - let declared = type.attributes + // A `use="prohibited"` use is excluded from the effective {attribute uses}, + // so a prohibited attribute is treated as undeclared and left to the + // attribute wildcard (which may still admit it) or reported as undeclared. + let declared = type.attributes.filter { !$0.prohibited } let elementName = element.name let wildcard = type.attributeWildcard for attribute in present where !declared.contains(where: { Self.attributeMatches(attribute.name, declared: $0.name, use: $0, on: elementName) }) { diff --git a/Sources/Schema/XSDAttributeParser.swift b/Sources/Schema/XSDAttributeParser.swift index 97d620a..ea86c93 100644 --- a/Sources/Schema/XSDAttributeParser.swift +++ b/Sources/Schema/XSDAttributeParser.swift @@ -47,39 +47,12 @@ extension PureXML.Schema.XSDParser { } static func attributeUse(_ node: XSDTree, _ context: PureXML.Schema.XSDContext) -> PureXML.Schema.AttributeUse? { - // An `` references a global attribute declaration: - // take its name (always target-namespace qualified) and type from the - // global node, but `use`/`default`/`fixed` from this reference site. if let ref = PureXML.Schema.XSDNode.attribute(node, "ref") { - let refName = PureXML.Schema.XSDNode.stripPrefix(ref) - let bindings = namespaceBindingsInScope(of: node, defaultBindings: context.namespaceBindings) - let namespace = PureXML.Schema.XSDNode.referenceNamespace(ref, bindings) - if namespace == "http://www.w3.org/XML/1998/namespace", refName == "base" { - return PureXML.Schema.AttributeUse( - name: PureXML.Model.QualifiedName(localName: refName, namespaceURI: namespace), - type: PureXML.Schema.SimpleType(base: .anyURI), - required: PureXML.Schema.XSDNode.attribute(node, "use") == "required", - valueConstraint: valueConstraint(of: node), - ) - } - guard let declaration = context.globalAttributes[refName], - var use = attributeUse(declaration, context.scoped(for: PureXML.Schema.XSDNode.schemaOwner(declaration))) - else { return nil } - let declContext = context.scoped(for: PureXML.Schema.XSDNode.schemaOwner(declaration)) - if let target = declContext.targetNamespace, !target.isEmpty { - use.name = PureXML.Model.QualifiedName(localName: refName, namespaceURI: target) - use.chameleonUnprefixed = isChameleonSchema(PureXML.Schema.XSDNode.schemaOwner(declaration)) - } else { - let bindings = namespaceBindingsInScope(of: node, defaultBindings: context.namespaceBindings) - let namespace = PureXML.Schema.XSDNode.referenceNamespace(ref, bindings) - use.name = PureXML.Model.QualifiedName(localName: refName, namespaceURI: namespace) - } - if PureXML.Schema.XSDNode.attribute(node, "use") == "required" { use.required = true } - if let constraint = valueConstraint(of: node) { use.valueConstraint = constraint } - return use + return referencedAttributeUse(node, ref: ref, context) } guard let name = PureXML.Schema.XSDNode.attribute(node, "name") else { return nil } - let required = PureXML.Schema.XSDNode.attribute(node, "use") == "required" + let use = PureXML.Schema.XSDNode.attribute(node, "use") + let required = use == "required" let type: PureXML.Schema.SimpleType = if let typeName = PureXML.Schema.XSDNode.attribute(node, "type") { PureXML.Schema.XSDSimpleParser.simpleTypeReference(typeName, context) } else if let inline = PureXML.Schema.XSDNode.firstChild(node, named: "simpleType") { @@ -98,9 +71,41 @@ extension PureXML.Schema.XSDParser { required: required, valueConstraint: valueConstraint(of: node), chameleonUnprefixed: qualified && isChameleonSchema(owner), + prohibited: use == "prohibited", ) } + /// An `` use: it takes its name (always target-namespace + /// qualified) and type from the referenced global declaration, but its + /// `use`/`default`/`fixed` from this reference site. + private static func referencedAttributeUse(_ node: XSDTree, ref: String, _ context: PureXML.Schema.XSDContext) -> PureXML.Schema.AttributeUse? { + let refName = PureXML.Schema.XSDNode.stripPrefix(ref) + let bindings = namespaceBindingsInScope(of: node, defaultBindings: context.namespaceBindings) + let namespace = PureXML.Schema.XSDNode.referenceNamespace(ref, bindings) + if namespace == "http://www.w3.org/XML/1998/namespace", refName == "base" { + return PureXML.Schema.AttributeUse( + name: PureXML.Model.QualifiedName(localName: refName, namespaceURI: namespace), + type: PureXML.Schema.SimpleType(base: .anyURI), + required: PureXML.Schema.XSDNode.attribute(node, "use") == "required", + valueConstraint: valueConstraint(of: node), + ) + } + guard let declaration = context.globalAttributes[refName], + var use = attributeUse(declaration, context.scoped(for: PureXML.Schema.XSDNode.schemaOwner(declaration))) + else { return nil } + let declContext = context.scoped(for: PureXML.Schema.XSDNode.schemaOwner(declaration)) + if let target = declContext.targetNamespace, !target.isEmpty { + use.name = PureXML.Model.QualifiedName(localName: refName, namespaceURI: target) + use.chameleonUnprefixed = isChameleonSchema(PureXML.Schema.XSDNode.schemaOwner(declaration)) + } else { + use.name = PureXML.Model.QualifiedName(localName: refName, namespaceURI: namespace) + } + if PureXML.Schema.XSDNode.attribute(node, "use") == "required" { use.required = true } + if PureXML.Schema.XSDNode.attribute(node, "use") == "prohibited" { use.prohibited = true } + if let constraint = valueConstraint(of: node) { use.valueConstraint = constraint } + return use + } + /// A schema document with no explicit `targetNamespace` (a chameleon include). private static func isChameleonSchema(_ schema: XSDTree) -> Bool { guard PureXML.Schema.XSDNode.localName(schema) == "schema" else { return false } diff --git a/Tests/SchemaProhibitedAttributeTests.swift b/Tests/SchemaProhibitedAttributeTests.swift new file mode 100644 index 0000000..296d183 --- /dev/null +++ b/Tests/SchemaProhibitedAttributeTests.swift @@ -0,0 +1,41 @@ +@testable import PureXML +import Testing + +/// `use="prohibited"` excludes an attribute from a complex type's effective +/// {attribute uses} (XSD 1.0 §3.4.2): the attribute is then undeclared, so its +/// presence is rejected when no wildcard admits it, but is still admitted (and +/// validated per `processContents`) when an attribute wildcard covers it. The +/// prohibited declaration is kept for schema-validity checks, not dropped. +@Suite("prohibited attribute use") +struct SchemaProhibitedAttributeTests { + @Test("a prohibited attribute with no wildcard is rejected when present") + func test_prohibitedNoWildcardRejected() throws { + let schema = try PureXML.Schema.Document(""" + + + + + + + """) + #expect(try !schema.validate(#""#).isEmpty) + // Omitting the prohibited attribute, with the allowed one, is valid. + #expect(try schema.validate(#""#).isEmpty) + } + + /// The W3C attZ002 shape: a prohibited attribute is still admitted by a lax + /// `anyAttribute` wildcard, so the instance is valid (the prohibition removes + /// the use, the wildcard independently admits the name). + @Test("a prohibited attribute admitted by a wildcard is accepted") + func test_prohibitedAdmittedByWildcardAccepted() throws { + let schema = try PureXML.Schema.Document(""" + + + + + + + """) + #expect(try schema.validate(#""#).isEmpty) + } +} diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 8e0231b..6027988 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 -> 43), per-step deltas in CHANGELOG.md: xsi:type must derive from the + /// Instance bucket (133 -> 39), 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 = 43 + private let knownInstanceInvalidAccepted = 39 @Test("Every XSTS case behaves: compile, reject, validate, invalidate") func test_suite() throws {