From f8ba6fef5ec42d42300331819060a0a43aee3974 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Thu, 18 Jun 2026 19:27:28 +0200 Subject: [PATCH] fix(schema): reject a constraining facet on anySimpleType-based simpleContent A constraining facet may not be applied where a simpleContent restriction's base chain resolves to xs:anySimpleType, the ur simple type, which carries no constraining facets (XSD Part 2 3.2.1); stZ010 (a complex type extending xs:anySimpleType, restricted by another that adds minLength) is now rejected. The base chain is followed through local complex types' own simpleContent bases; it stops at any other built-in, a simple type, or an external/unresolved base, and a restriction supplying its own nested simpleType base is skipped (the facet applies to that), so a facet on a string-, int-, or other real-typed simpleContent stays accepted. XSTS invalid-schemas-accepted 49 -> 48, no false positive (valid-schemas-rejected and valid-instances-rejected held at 0; an adversarial critic probed 12 valid schemas across every resolver branch and confirmed FP-safety), no other bucket moved. --- CHANGELOG.md | 2 + Sources/Schema/SchemaFacetApplicability.swift | 65 +++++++++++++++++++ Sources/Schema/SchemaStructure.swift | 2 +- Tests/SchemaAnySimpleTypeFacetTests.swift | 46 +++++++++++++ Tests/XSTSSuiteTests.swift | 2 +- 5 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 Tests/SchemaAnySimpleTypeFacetTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b864005..edee316 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-schemas-accepted (#145): a constraining facet may not be applied where a `simpleContent` restriction's base chain resolves to `xs:anySimpleType`, the ur simple type, which carries no constraining facets (XSD Part 2 §3.2.1); stZ010 (a complex type extending `xs:anySimpleType`, restricted by another that adds `minLength`) is now rejected. The base chain is followed through local complex types' own `simpleContent` bases; a base that bottoms at any other built-in, a simple type, or an external/unresolved type is left alone, and a restriction supplying its own nested `simpleType` base is skipped (the facet applies to that), so a facet on a string-, int-, or other real-typed simpleContent stays accepted. invalid-schemas-accepted 49 → 48, no false positive (valid-schemas-rejected and valid-instances-rejected held at 0), no other bucket moved. + - XSTS invalid-schemas-accepted (#145, #161): a redefinition of an attribute group may not ADD an attribute the original does not declare (a superset is not a restriction, src-redefine / cos-ct-restricts); schM3 (redeclaring the original's attributes plus a new `b4`) is now rejected. The base is resolved by namespaced identity with a `{}name` chameleon fallback (a no-targetNamespace redefined schema is absorbed into the redefining namespace). To stay false-positive-free the added-attribute check runs only when the original's attribute set is fully known from its direct declarations (the original has no nested `attributeGroup` reference) AND the redefinition itself carries no `attributeGroup` reference: a self-reference brings in the original's attributes, so declaring further ones alongside it is a valid redefine (W3C attgC007/attgC038/groupB018, and test75083/xsd003b), not an addition. invalid-schemas-accepted 51 → 49, no false positive (valid-schemas-rejected and valid-instances-rejected held at 0; five self-reference over-rejections surfaced by the gate were fixed before commit), no other bucket moved. - XSTS invalid-schemas-accepted (#145, #161): a non-self-referencing model group redefined inside `xs:redefine` must be a valid RESTRICTION of the group it redefines (`cos-group-restrict` / `src-redefine` 6.1.2), so its content model may not widen the original's language. The check reuses the same particle-restriction oracle as complex-type restriction, resolving the original group by namespaced identity (with a chameleon `{}name` fallback for a no-targetNamespace redefined schema). Cases now rejected include adding required content to an empty base (schZ006), dropping a required `all` member so documents without it become valid (schL1), reordering an `all` (schL5/L6, since the spec's Recurse rule is order-preserving even for `all`), and growing a group to a superset (schL8), plus the multi-document and chained redefines schN4/5/6/7 and schL10. To stay false-positive-free the check skips a redefinition carrying a self-reference (governed by the self-reference well-formedness rule and not re-parseable here) and declines to judge a base containing a `maxOccurs=0` "pointless particle" (the contested normalization case where W3C accepts a redefinition re-enabling that member, mgO013); a genuine restriction (dropping an optional member, an unchanged redefinition) is accepted. invalid-schemas-accepted 64 → 51, no false positive (valid-schemas-rejected and valid-instances-rejected held at 0; an over-rejection of mgO013 surfaced by the gate was fixed before commit), no other bucket moved. Disclosed under-rejections: self-referencing redefinitions (schN10) and `maxOccurs=0`-base redefines. diff --git a/Sources/Schema/SchemaFacetApplicability.swift b/Sources/Schema/SchemaFacetApplicability.swift index 9865f59..26647c2 100644 --- a/Sources/Schema/SchemaFacetApplicability.swift +++ b/Sources/Schema/SchemaFacetApplicability.swift @@ -302,4 +302,69 @@ extension PureXML.Schema.XSDParser { forEachRestrictionInSimpleType(child, visit) } } + + /// A constraining facet may not be applied where a `simpleContent` restriction's + /// base chain resolves to `xs:anySimpleType`, the ur simple type, which carries no + /// constraining facets (XSD Part 2 §3.2.1): e.g. a complex type extending + /// `xs:anySimpleType`, restricted by another adding `minLength`. The chain is + /// followed through local complex types' own simpleContent bases; a base that is a + /// built-in other than anySimpleType, a simple-type, or external/unresolved is left + /// alone, so no valid facet is rejected. A restriction supplying its own nested + /// `simpleType` base is also skipped (the facet applies to that, not anySimpleType). + static func anySimpleTypeFacetErrors(_ schema: XSDTree) -> [String] { + let bindings = namespaceBindings(schema) + let target = PureXML.Schema.XSDNode.attribute(schema, "targetNamespace") + var errors: [String] = [] + forEachSimpleContentRestriction(schema) { restriction in + guard PureXML.Schema.XSDNode.firstChild(restriction, named: "simpleType") == nil, + let base = PureXML.Schema.XSDNode.attribute(restriction, "base"), + hasConstrainingFacet(restriction), + simpleContentBaseIsAnySimpleType(base, schema, bindings, target, []) + else { return } + errors.append("a constraining facet may not be applied to anySimpleType-based simpleContent") + } + return errors + } + + private static func hasConstrainingFacet(_ restriction: XSDTree) -> Bool { + PureXML.Schema.XSDNode.elementChildren(restriction) + .compactMap(PureXML.Schema.XSDNode.localName) + .contains { facetNames.contains($0) } + } + + /// Whether the `simpleContent` `base` chain bottoms at `xs:anySimpleType`. Follows a + /// base naming a local complex type's `simpleContent` to that type's own base; + /// stops (false) at any other built-in, a simple-type base, or an unresolved one. + private static func simpleContentBaseIsAnySimpleType( + _ base: String, _ schema: XSDTree, _ bindings: [String: String], _ target: String?, _ visited: Set, + ) -> Bool { + let (prefix, local) = splitQName(base) + let uri = prefix.map { bindings[$0] } ?? bindings[""] + if uri == xsdNamespace { return local == "anySimpleType" } + guard uri == target, !visited.contains(local), + let complexType = globalComplexType(named: local, schema), + let simpleContent = PureXML.Schema.XSDNode.firstChild(complexType, named: "simpleContent"), + let derivation = PureXML.Schema.XSDNode.firstChild(simpleContent, named: "extension") + ?? PureXML.Schema.XSDNode.firstChild(simpleContent, named: "restriction"), + let baseRef = PureXML.Schema.XSDNode.attribute(derivation, "base") + else { return false } + return simpleContentBaseIsAnySimpleType(baseRef, schema, bindings, target, visited.union([local])) + } + + private static func globalComplexType(named name: String, _ schema: XSDTree) -> XSDTree? { + PureXML.Schema.XSDNode.elementChildren(schema).first { + PureXML.Schema.XSDNode.localName($0) == "complexType" && PureXML.Schema.XSDNode.attribute($0, "name") == name + } + } + + private static func forEachSimpleContentRestriction(_ node: XSDTree, _ visit: (XSDTree) -> Void) { + let local = PureXML.Schema.XSDNode.localName(node) + if local == "appinfo" || local == "documentation" { return } + if local == "simpleContent", let restriction = PureXML.Schema.XSDNode.firstChild(node, named: "restriction") { + visit(restriction) + } + for child in PureXML.Schema.XSDNode.elementChildren(node) { + forEachSimpleContentRestriction(child, visit) + } + } } diff --git a/Sources/Schema/SchemaStructure.swift b/Sources/Schema/SchemaStructure.swift index 9da4321..bb2f520 100644 --- a/Sources/Schema/SchemaStructure.swift +++ b/Sources/Schema/SchemaStructure.swift @@ -65,7 +65,7 @@ extension PureXML.Schema.XSDParser { append(valueConstraintErrors(schema), at: schema, into: &findings) append(topLevelDeclarationErrors(schema), at: schema, into: &findings) append(nestedNamedDefinitionErrors(schema), at: schema, into: &findings) - append(anySimpleTypeRestrictionErrors(schema), at: schema, into: &findings) + append(anySimpleTypeRestrictionErrors(schema) + anySimpleTypeFacetErrors(schema), at: schema, into: &findings) append(emptyNamespaceErrors(schema), at: schema, into: &findings) return findings } diff --git a/Tests/SchemaAnySimpleTypeFacetTests.swift b/Tests/SchemaAnySimpleTypeFacetTests.swift new file mode 100644 index 0000000..cca59c5 --- /dev/null +++ b/Tests/SchemaAnySimpleTypeFacetTests.swift @@ -0,0 +1,46 @@ +@testable import PureXML +import Testing + +/// A constraining facet may not be applied where a `simpleContent` restriction's base +/// chain resolves to `xs:anySimpleType`, the ur simple type, which carries no +/// constraining facets (XSD Part 2 §3.2.1, W3C stZ010). A base that bottoms at a real +/// built-in or a user simple type still admits its applicable facets. +@Suite("anySimpleType simpleContent facet applicability") +struct SchemaAnySimpleTypeFacetTests { + private func compile(_ schema: String) throws -> PureXML.Schema.Document { + try PureXML.Schema.Document(schema) + } + + private func chain(base: String, facet: String) -> String { + "" + + "" + + "\(facet)" + + "" + } + + @Test("a facet on an anySimpleType-based simpleContent restriction is rejected") + func test_facetOnAnySimpleTypeRejected() throws { + #expect(throws: (any Error).self) { + try compile(chain(base: "xs:anySimpleType", facet: "")) + } + } + + @Test("minLength on a string-based simpleContent restriction is valid") + func test_facetOnStringAccepted() throws { + _ = try compile(chain(base: "xs:string", facet: "")) + } + + @Test("maxInclusive on an int-based simpleContent restriction is valid") + func test_facetOnIntAccepted() throws { + _ = try compile(chain(base: "xs:int", facet: "")) + } + + @Test("an anySimpleType extension with no facet restriction is valid") + func test_anySimpleTypeWithoutFacetAccepted() throws { + _ = try compile( + "" + + "" + + "", + ) + } +} diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift index aaa7a83..4eeeed5 100644 --- a/Tests/XSTSSuiteTests.swift +++ b/Tests/XSTSSuiteTests.swift @@ -148,7 +148,7 @@ struct XSTSSuiteTests { /// and the cross-document composition family (src-resolve, src-import.3, /// src-redefine.5/6.1/7.2.1, redefine cross-namespace, required schemaLocation). private let knownSchemaValidRejected = 0 - private let knownSchemaInvalidAccepted = 49 + private let knownSchemaInvalidAccepted = 48 private let knownInstanceValidRejected = 0 /// Instance bucket (133 -> 31), 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);