fix(schema): enforce attribute-use uniqueness and the single-ID rule
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 (ct-props-correct.4 / ag-props-correct.2), nor more than one attribute whose type is or derives from xs:ID (ct-props-correct.5). Declarations are deduplicated by the identity of their declaring node, so the same declaration reached by two attribute-group paths (a diamond) is one use, not a clash. Names carry their resolved namespace (form / attributeFormDefault for locals, the prefix binding for refs), so a local attribute and a reference to a same-local-name attribute in an imported namespace do not collide. The check runs after named simple types are resolved, so a named user type derived from xs:ID is recognized, not just the built-in and inline forms. XSTS: invalid schemas accepted 505 to 495 (attQ008/009/012/013, attgD006/007/ 009/017, ctM003/004 now rejected), no other bucket raised. An adversarial critic caught a false positive on same-local-name-different-namespace (fixed by resolving the ref namespace) and a missed named-user-ID type (fixed by ordering the check after type resolution). Disclosed under-rejections: the set inherited through extension is not composed in; a duplicate needing an unprefixed reference with no default namespace is not detected.
This commit is contained in:
parent
7c059e1355
commit
067f04184f
@ -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).
|
||||
|
||||
|
||||
106
Sources/Schema/SchemaAttributeUniqueness.swift
Normal file
106
Sources/Schema/SchemaAttributeUniqueness.swift
Normal file
@ -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<String> = []) -> [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<ObjectIdentifier> = []
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
56
Tests/SchemaAttributeUniquenessTests.swift
Normal file
56
Tests/SchemaAttributeUniquenessTests.swift
Normal file
@ -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("<xs:schema \(xsd)><xs:complexType name=\"T\">"
|
||||
+ "<xs:attribute name=\"a\" type=\"xs:string\"/><xs:attribute name=\"a\" type=\"xs:int\"/>"
|
||||
+ "</xs:complexType></xs:schema>"))
|
||||
// A local attribute colliding with one pulled in through an attribute group.
|
||||
#expect(!compiles("<xs:schema \(xsd)>"
|
||||
+ "<xs:attributeGroup name=\"g\"><xs:attribute name=\"a\" type=\"xs:string\"/></xs:attributeGroup>"
|
||||
+ "<xs:complexType name=\"T\"><xs:attribute name=\"a\" type=\"xs:int\"/><xs:attributeGroup ref=\"g\"/></xs:complexType></xs:schema>"))
|
||||
// Two same-named attributes within one attribute group (ag-props-correct.2).
|
||||
#expect(!compiles("<xs:schema \(xsd)><xs:attributeGroup name=\"g\">"
|
||||
+ "<xs:attribute name=\"a\"/><xs:attribute name=\"a\"/></xs:attributeGroup></xs:schema>"))
|
||||
}
|
||||
|
||||
@Test("More than one ID-typed attribute is rejected (ct-props-correct.5)")
|
||||
func test_twoIDAttributesRejected() {
|
||||
#expect(!compiles("<xs:schema \(xsd)><xs:complexType name=\"T\">"
|
||||
+ "<xs:attribute name=\"a\" type=\"xs:ID\"/><xs:attribute name=\"b\" type=\"xs:ID\"/>"
|
||||
+ "</xs:complexType></xs:schema>"))
|
||||
// A single ID attribute is fine.
|
||||
#expect(compiles("<xs:schema \(xsd)><xs:complexType name=\"T\">"
|
||||
+ "<xs:attribute name=\"a\" type=\"xs:ID\"/><xs:attribute name=\"b\" type=\"xs:string\"/>"
|
||||
+ "</xs:complexType></xs:schema>"))
|
||||
}
|
||||
|
||||
@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("<xs:schema \(xsd) targetNamespace=\"urn:t\" xmlns:t=\"urn:t\""
|
||||
+ " xmlns:imp=\"urn:imp\" attributeFormDefault=\"qualified\">"
|
||||
+ "<xs:import namespace=\"urn:imp\"/>"
|
||||
+ "<xs:attributeGroup name=\"g\"><xs:attribute name=\"a\"/><xs:attribute ref=\"imp:a\"/></xs:attributeGroup>"
|
||||
+ "<xs:complexType name=\"T\"><xs:attributeGroup ref=\"t:g\"/></xs:complexType></xs:schema>"))
|
||||
// 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("<xs:schema \(xsd)>"
|
||||
+ "<xs:attributeGroup name=\"g3\"><xs:attribute name=\"a\"/></xs:attributeGroup>"
|
||||
+ "<xs:attributeGroup name=\"g1\"><xs:attributeGroup ref=\"g3\"/></xs:attributeGroup>"
|
||||
+ "<xs:attributeGroup name=\"g2\"><xs:attributeGroup ref=\"g3\"/></xs:attributeGroup>"
|
||||
+ "<xs:complexType name=\"T\"><xs:attributeGroup ref=\"g1\"/><xs:attributeGroup ref=\"g2\"/></xs:complexType></xs:schema>"))
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 | |
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user