diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c66db1..da4598b 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 +- A `type` attribute and an inline anonymous type definition are now mutually exclusive on an element or attribute (XSD 1.0 `src-element.3` / `src-attribute.3.1`, #170). An `` or `` carrying both a `type=` and an inline `` (or ``, for an element) is rejected; previously it was accepted. Only an inline type in the XSD namespace counts, so a foreign-namespace child that happens to be named `complexType`/`simpleType` is not mistaken for the component's own type. XSTS: invalid schemas accepted 495 to 489, no other bucket raised. - 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/SchemaAttributeApplicability.swift b/Sources/Schema/SchemaAttributeApplicability.swift new file mode 100644 index 0000000..41ca92d --- /dev/null +++ b/Sources/Schema/SchemaAttributeApplicability.swift @@ -0,0 +1,91 @@ +extension PureXML.Schema.XSDParser { + /// The unprefixed attributes each XSD component admits (XSD 1.0 schema for + /// schemas). The element/attribute entries are the permissive union across + /// global and local use (so a context-only attribute like `form` or + /// `minOccurs` is never wrongly flagged); `id` is admitted everywhere. An + /// attribute from a foreign namespace (prefixed) is always allowed and is not + /// listed here. + private static let allowedAttributes: [String: Set] = { + let facetAttrs: Set = ["value", "fixed", "id"] + var table: [String: Set] = [ + "schema": ["targetNamespace", "version", "finalDefault", "blockDefault", "attributeFormDefault", "elementFormDefault", "id"], + "element": ["name", "ref", "type", "minOccurs", "maxOccurs", "default", "fixed", "nillable", "abstract", "substitutionGroup", "final", "block", "form", "id"], + "attribute": ["name", "ref", "type", "use", "default", "fixed", "form", "id"], + "complexType": ["name", "abstract", "final", "block", "mixed", "id"], + "simpleType": ["name", "final", "id"], + "complexContent": ["mixed", "id"], + "simpleContent": ["id"], + "restriction": ["base", "id"], + "extension": ["base", "id"], + "group": ["name", "ref", "minOccurs", "maxOccurs", "id"], + "attributeGroup": ["name", "ref", "id"], + "sequence": ["minOccurs", "maxOccurs", "id"], + "choice": ["minOccurs", "maxOccurs", "id"], + "all": ["minOccurs", "maxOccurs", "id"], + "any": ["namespace", "processContents", "minOccurs", "maxOccurs", "id"], + "anyAttribute": ["namespace", "processContents", "id"], + "unique": ["name", "id"], + "key": ["name", "id"], + "keyref": ["name", "refer", "id"], + "selector": ["xpath", "id"], + "field": ["xpath", "id"], + "list": ["itemType", "id"], + "union": ["memberTypes", "id"], + "import": ["namespace", "schemaLocation", "id"], + "include": ["schemaLocation", "id"], + "redefine": ["schemaLocation", "id"], + "notation": ["name", "public", "system", "id"], + "annotation": ["id"], + ] + for facet in ["minExclusive", "minInclusive", "maxExclusive", "maxInclusive", "totalDigits", "fractionDigits", "length", "minLength", "maxLength", "whiteSpace"] { + table[facet] = facetAttrs + } + for facet in ["enumeration", "pattern"] { + table[facet] = ["value", "id"] + } + return table + }() + + /// Findings for the unprefixed attributes on `node`: any not admitted by its + /// component, the `ref` exclusions (`ref` excludes `name` and `type`), and the + /// `type`-excludes-inline-type exclusion. A prefixed (foreign-namespace) + /// attribute and a namespace declaration are not the component's own and are + /// not checked. + static func attributeApplicabilityErrors(_ node: XSDTree, local: String) -> [String] { + guard let allowed = allowedAttributes[local] else { return [] } + var errors: [String] = [] + var present: Set = [] + for attribute in node.attributes where attribute.name.prefix == nil && attribute.name.localName != "xmlns" { + let name = attribute.name.localName + present.insert(name) + if !allowed.contains(name) { + errors.append("attribute '\(name)' is not allowed on '\(local)'") + } + } + if present.contains("ref"), present.contains("name") { + errors.append("'\(local)' may not have both 'ref' and 'name'") + } + if present.contains("ref"), present.contains("type") { + errors.append("'\(local)' may not have both 'ref' and 'type'") + } + // src-element.3 / src-attribute.3-4: a `type` attribute and an inline + // anonymous type definition are mutually exclusive. An attribute may carry + // only an inline `simpleType`; an element may carry a `simpleType` or + // `complexType`. + if present.contains("type"), local == "element" || local == "attribute", hasInlineType(node, local) { + errors.append("'\(local)' may not have both a 'type' attribute and an inline type definition") + } + return errors + } + + private static func hasInlineType(_ node: XSDTree, _ local: String) -> Bool { + PureXML.Schema.XSDNode.elementChildren(node).contains { child in + // Only an inline type in the XSD namespace counts; a foreign-namespace + // child that happens to be named `complexType`/`simpleType` is not the + // component's own type definition (foreign content is not checked here). + guard child.name?.namespaceURI == xsdNamespace else { return false } + let kind = PureXML.Schema.XSDNode.localName(child) + return kind == "simpleType" || (local == "element" && kind == "complexType") + } + } +} diff --git a/Sources/Schema/SchemaStructure.swift b/Sources/Schema/SchemaStructure.swift index 6f8414b..6db26fc 100644 --- a/Sources/Schema/SchemaStructure.swift +++ b/Sources/Schema/SchemaStructure.swift @@ -61,77 +61,6 @@ extension PureXML.Schema.XSDParser { return errors } - /// The unprefixed attributes each XSD component admits (XSD 1.0 schema for - /// schemas). The element/attribute entries are the permissive union across - /// global and local use (so a context-only attribute like `form` or - /// `minOccurs` is never wrongly flagged); `id` is admitted everywhere. An - /// attribute from a foreign namespace (prefixed) is always allowed and is not - /// listed here. - private static let allowedAttributes: [String: Set] = { - let facetAttrs: Set = ["value", "fixed", "id"] - var table: [String: Set] = [ - "schema": ["targetNamespace", "version", "finalDefault", "blockDefault", "attributeFormDefault", "elementFormDefault", "id"], - "element": ["name", "ref", "type", "minOccurs", "maxOccurs", "default", "fixed", "nillable", "abstract", "substitutionGroup", "final", "block", "form", "id"], - "attribute": ["name", "ref", "type", "use", "default", "fixed", "form", "id"], - "complexType": ["name", "abstract", "final", "block", "mixed", "id"], - "simpleType": ["name", "final", "id"], - "complexContent": ["mixed", "id"], - "simpleContent": ["id"], - "restriction": ["base", "id"], - "extension": ["base", "id"], - "group": ["name", "ref", "minOccurs", "maxOccurs", "id"], - "attributeGroup": ["name", "ref", "id"], - "sequence": ["minOccurs", "maxOccurs", "id"], - "choice": ["minOccurs", "maxOccurs", "id"], - "all": ["minOccurs", "maxOccurs", "id"], - "any": ["namespace", "processContents", "minOccurs", "maxOccurs", "id"], - "anyAttribute": ["namespace", "processContents", "id"], - "unique": ["name", "id"], - "key": ["name", "id"], - "keyref": ["name", "refer", "id"], - "selector": ["xpath", "id"], - "field": ["xpath", "id"], - "list": ["itemType", "id"], - "union": ["memberTypes", "id"], - "import": ["namespace", "schemaLocation", "id"], - "include": ["schemaLocation", "id"], - "redefine": ["schemaLocation", "id"], - "notation": ["name", "public", "system", "id"], - "annotation": ["id"], - ] - for facet in ["minExclusive", "minInclusive", "maxExclusive", "maxInclusive", "totalDigits", "fractionDigits", "length", "minLength", "maxLength", "whiteSpace"] { - table[facet] = facetAttrs - } - for facet in ["enumeration", "pattern"] { - table[facet] = ["value", "id"] - } - return table - }() - - /// Findings for the unprefixed attributes on `node`: any not admitted by its - /// component, and the `ref` exclusions (`ref` excludes `name` and `type`). A - /// prefixed (foreign-namespace) attribute and a namespace declaration are not - /// the component's own and are not checked. - private static func attributeApplicabilityErrors(_ node: XSDTree, local: String) -> [String] { - guard let allowed = allowedAttributes[local] else { return [] } - var errors: [String] = [] - var present: Set = [] - for attribute in node.attributes where attribute.name.prefix == nil && attribute.name.localName != "xmlns" { - let name = attribute.name.localName - present.insert(name) - if !allowed.contains(name) { - errors.append("attribute '\(name)' is not allowed on '\(local)'") - } - } - if present.contains("ref"), present.contains("name") { - errors.append("'\(local)' may not have both 'ref' and 'name'") - } - if present.contains("ref"), present.contains("type") { - errors.append("'\(local)' may not have both 'ref' and 'type'") - } - return errors - } - private static func collectStructure(_ node: XSDTree, into errors: inout [String]) { let local = PureXML.Schema.XSDNode.localName(node) if local == "appinfo" || local == "documentation" { return } diff --git a/Tests/SchemaTypeExclusivityTests.swift b/Tests/SchemaTypeExclusivityTests.swift new file mode 100644 index 0000000..3a5a4ca --- /dev/null +++ b/Tests/SchemaTypeExclusivityTests.swift @@ -0,0 +1,41 @@ +@testable import PureXML +import Testing + +@Suite("XSD type attribute excludes an inline type") +struct SchemaTypeExclusivityTests { + private let xsd = "xmlns:xs=\"http://www.w3.org/2001/XMLSchema\"" + + private func compiles(_ body: String) -> Bool { + (try? PureXML.Schema.Document("\(body)")) != nil + } + + @Test("type attribute and an inline type definition are mutually exclusive") + func test_typeAndInlineTypeRejected() { + // Element with both type= and an inline simpleType (src-element.3). + #expect(!compiles("" + + "")) + // Element with both type= and an inline complexType. + #expect(!compiles("")) + // Attribute with both type= and an inline simpleType (src-attribute.3.1). + #expect(!compiles("" + + "")) + } + + @Test("type alone, or an inline type alone, compiles") + func test_eitherAloneAccepted() { + #expect(compiles("")) + #expect(compiles("")) + #expect(compiles("")) + // type= alongside an identity constraint (not a type) is legal. + #expect(compiles("")) + } + + @Test("A foreign-namespace child named simpleType/complexType is not the component's inline type") + func test_foreignNamespaceChildNotInlineType() { + // A foreign child whose local name happens to be complexType is not an XSD + // inline type, so type= alongside it is not the type/inline-type clash. + let schema = "" + + "" + #expect((try? PureXML.Schema.Document(schema)) != nil) + } +} diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index 2dee5a8..7860bca 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 = 495 + private let knownSchemaInvalidAccepted = 489 private let knownInstanceValidRejected = 180 private let knownInstanceInvalidAccepted = 160