diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f43aac..4c66db1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Attribute-use uniqueness and the single-ID rule are now enforced (XSD 1.0 `ct-props-correct.4` / `ag-props-correct.2` / `ct-props-correct.5`, #162). A complex type's or attribute group's attribute-use set (flattened through `attributeGroup` references) may not contain two distinct declarations with the same name and namespace, nor more than one attribute whose type is or derives from `xs:ID`. Declarations are deduplicated by the identity of their declaring node, so the same declaration reached by two paths (a diamond of attribute-group references) is one use, not a clash; names carry their resolved namespace, so a local attribute and a reference to a same-local-name attribute in an imported namespace do not collide. XSTS: invalid schemas accepted 505 to 495, no other bucket raised. Disclosed under-rejections: the set inherited through extension is not composed in, and a duplicate that requires an unprefixed reference in a schema with no default namespace is not detected. - Circular references among model groups, attribute groups, and substitution-group affiliations are now rejected (XSD 1.0 `mg-props-correct.2`, `ag-props-correct.3`, `e-props-correct.6`, #168). A model group may not contain itself, an attribute group may not reference itself transitively, and a substitution chain may not loop. The model-group check follows references only within a definition's own compositor tree, stopping at element and type boundaries: a group referenced inside an element's content is a recursive data structure (permitted), not the group containing itself. A reference is followed only when it resolves to the schema's own target namespace, and a group or attribute group redefined in `xs:redefine` (whose single self-reference is the legal redefinition) ends the chain. XSTS: invalid schemas accepted 516 to 505, no other bucket raised. A genuine cycle introduced across two redefined definitions is a disclosed under-rejection. - Circular type derivation is now rejected at compile time (XSD 1.0 `ct-props-correct.3` / `st-props-correct.2`, #168). A complex or simple type may not derive, transitively through its base, from itself; previously such a schema was accepted. Only the base chain is followed, so a type whose element content recurses (an element of its own type, the normal recursive-data-structure case) is correctly permitted, and a type redefined in `xs:redefine` (whose base legitimately names its former self) and the ur-types (`anyType`/`anySimpleType`/`anyAtomicType`, the roots the schema-for-schemas bootstraps) end the chain rather than appear as cycles. The base is followed only when it resolves to the schema's own target namespace, so a type whose base is a same-local-name type in another namespace is not mistaken for a self-cycle. XSTS: invalid schemas accepted 519 to 516, no other bucket raised. Union/list (`itemType`/`memberTypes`) and genuine cross-namespace derivation cycles are a disclosed under-rejection (the engine resolves by local name). diff --git a/Sources/Schema/SchemaAttributeUniqueness.swift b/Sources/Schema/SchemaAttributeUniqueness.swift new file mode 100644 index 0000000..8377812 --- /dev/null +++ b/Sources/Schema/SchemaAttributeUniqueness.swift @@ -0,0 +1,106 @@ +private typealias AttrNode = PureXML.Schema.XSDNode + +/// One reached attribute declaration: the identity of its declaring node (so the +/// same declaration reached by two paths is one), its resolved name, and whether +/// its type is ID-derived. +private struct ReachedAttribute { + let id: ObjectIdentifier + let name: PureXML.Model.QualifiedName + let isID: Bool +} + +extension PureXML.Schema.XSDParser { + /// Schema-validity findings for attribute-use uniqueness (XSD 1.0 + /// `ct-props-correct.4` / `ag-props-correct.2`) and the single-ID rule + /// (`ct-props-correct.5`): a complex type's or attribute group's complete + /// attribute-use set may not contain two distinct declarations whose names and + /// namespaces match, nor more than one attribute whose type is (or derives + /// from) `xs:ID`. + /// + /// The set is flattened through `attributeGroup` references and declarations are + /// deduplicated by identity, so the same declaration reached by two paths (a + /// diamond of attribute-group references) counts once, not as a clash. Names + /// carry their resolved namespace, so a qualified and an unqualified attribute + /// of the same local name do not collide. The own-declared set is checked, not + /// the set inherited through extension (a disclosed under-rejection). + static func attributeUseErrors(_ containers: [XSDTree], _ context: PureXML.Schema.XSDContext) -> [String] { + var errors: [String] = [] + for container in containers { + for group in descendants(container, named: "attributeGroup") { + guard let name = AttrNode.attribute(group, "name") else { continue } + errors += attributeSetErrors(attributeDeclarations(under: group, context), "attribute group '\(name)'") + } + for type in descendants(container, named: "complexType") { + let label = AttrNode.attribute(type, "name").map { "complex type '\($0)'" } ?? "an anonymous complex type" + errors += attributeSetErrors(attributeDeclarations(under: attributeContainer(of: type), context), label) + } + } + return errors + } + + /// The node whose direct children declare a complex type's attributes: the + /// `complexContent`/`simpleContent` derivation when present, else the type + /// itself (the shorthand form). + private static func attributeContainer(of type: XSDTree) -> XSDTree { + guard let derivation = AttrNode.firstChild(type, named: "complexContent") ?? AttrNode.firstChild(type, named: "simpleContent"), + let body = AttrNode.firstChild(derivation, named: "extension") ?? AttrNode.firstChild(derivation, named: "restriction") + else { + return type + } + return body + } + + /// Each attribute declaration reachable under `node`, paired with the identity + /// of its declaring node (a `ref` resolves to the one global declaration, so two + /// references to it share identity), its resolved name, and whether its type is + /// ID-derived. + private static func attributeDeclarations(under node: XSDTree, _ context: PureXML.Schema.XSDContext, visited: Set = []) -> [ReachedAttribute] { + var result: [ReachedAttribute] = [] + for child in AttrNode.elementChildren(node) { + switch AttrNode.localName(child) { + case "attribute": + if let ref = AttrNode.attribute(child, "ref") { + let refName = AttrNode.stripPrefix(ref) + guard let declaration = context.globalAttributes[refName], let use = attributeUse(declaration, context) else { break } + // The reference's namespace is its prefix's binding (an imported + // attribute is in the imported namespace), not the target namespace, + // so `a` and `imp:a` are different attributes, not a clash. + let namespace = AttrNode.referenceNamespace(ref, context.namespaceBindings) + result.append(ReachedAttribute( + id: ObjectIdentifier(declaration), + name: PureXML.Model.QualifiedName(localName: refName, namespaceURI: namespace), + isID: use.type.base == .id, + )) + } else if let use = attributeUse(child, context) { + result.append(ReachedAttribute(id: ObjectIdentifier(child), name: use.name, isID: use.type.base == .id)) + } + case "attributeGroup": + guard let ref = AttrNode.attribute(child, "ref") else { break } + let name = AttrNode.stripPrefix(ref) + guard !visited.contains(name), let group = context.attributeGroups[name] else { break } + result += attributeDeclarations(under: group, context, visited: visited.union([name])) + default: + break + } + } + return result + } + + private static func attributeSetErrors(_ declarations: [ReachedAttribute], _ label: String) -> [String] { + var seenIDs: Set = [] + var counts: [PureXML.Model.QualifiedName: Int] = [:] + var idCount = 0 + for declaration in declarations where seenIDs.insert(declaration.id).inserted { + counts[declaration.name, default: 0] += 1 + if declaration.isID { idCount += 1 } + } + var errors: [String] = [] + for name in counts.keys.sorted(by: { ($0.localName, $0.namespaceURI ?? "") < ($1.localName, $1.namespaceURI ?? "") }) where (counts[name] ?? 0) > 1 { + errors.append("\(label) has more than one attribute named '\(name.localName)'") + } + if idCount > 1 { + errors.append("\(label) has more than one attribute of type ID") + } + return errors + } +} diff --git a/Sources/Schema/XSDParser.swift b/Sources/Schema/XSDParser.swift index 52f0510..61606ac 100644 --- a/Sources/Schema/XSDParser.swift +++ b/Sources/Schema/XSDParser.swift @@ -39,7 +39,9 @@ extension PureXML.Schema { } var types = namedTypes(containers, into: &context) let elements = globalElements(containers, context, into: &types) - for error in referenceErrors(schema, in: context, elements: elements) { + // Attribute-use uniqueness / single-ID run here, after `namedTypes` + // populated `simpleTypes`, so a named user type derived from `xs:ID` counts. + for error in referenceErrors(schema, in: context, elements: elements) + attributeUseErrors(containers, context) { context.diagnostics.report(error) } let (nillable, elementConstraints) = elementMetadata(containers) diff --git a/Tests/SchemaAttributeUniquenessTests.swift b/Tests/SchemaAttributeUniquenessTests.swift new file mode 100644 index 0000000..678fb24 --- /dev/null +++ b/Tests/SchemaAttributeUniquenessTests.swift @@ -0,0 +1,56 @@ +@testable import PureXML +import Testing + +@Suite("XSD attribute-use uniqueness and single ID") +struct SchemaAttributeUniquenessTests { + private func compiles(_ source: String) -> Bool { + (try? PureXML.Schema.Document(source)) != nil + } + + private let xsd = "xmlns:xs=\"http://www.w3.org/2001/XMLSchema\"" + + @Test("Two attribute uses with the same name in one type or group are rejected") + func test_duplicateAttributeRejected() { + // Two local attributes of the same name directly in a complex type. + #expect(!compiles("" + + "" + + "")) + // A local attribute colliding with one pulled in through an attribute group. + #expect(!compiles("" + + "" + + "")) + // Two same-named attributes within one attribute group (ag-props-correct.2). + #expect(!compiles("" + + "")) + } + + @Test("More than one ID-typed attribute is rejected (ct-props-correct.5)") + func test_twoIDAttributesRejected() { + #expect(!compiles("" + + "" + + "")) + // A single ID attribute is fine. + #expect(compiles("" + + "" + + "")) + } + + @Test("Same local name in different namespaces, and a diamond, are not clashes") + func test_distinctNamespacesAndDiamondAccepted() { + // attQ010/attQ019: a local attribute and a reference to a same-local-name + // attribute in an imported namespace are different attributes. (Must compile.) + #expect(compiles("" + + "" + + "" + + "")) + // A diamond of attribute-group references reaching the same attribute once is + // not a duplicate: g3 has attribute a; g1 and g2 both include g3; T includes + // both. (Must compile.) + #expect(compiles("" + + "" + + "" + + "" + + "")) + } +} diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 67e836c..2dee5a8 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 = 505 + private let knownSchemaInvalidAccepted = 495 private let knownInstanceValidRejected = 180 private let knownInstanceInvalidAccepted = 160 diff --git a/docs/schema-validity-burndown.md b/docs/schema-validity-burndown.md index 6876176..ad9d7a9 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 -> 505** | 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 and circular group/attributeGroup/substitution references landed). | +| **invalid-schemas-accepted** | **2461 -> 495** | 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 landed). | | valid-instances-rejected | **233 -> 180** | (instance side; #164's element-ref namespace fix is shared with instance validation, -34) | | invalid-instances-accepted | 160 | |