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.
This commit is contained in:
Mihaela Mihaljevic 2026-06-18 19:27:28 +02:00
parent afad5f822c
commit f8ba6fef5e
5 changed files with 115 additions and 2 deletions

View File

@ -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.

View File

@ -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<String>,
) -> 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)
}
}
}

View File

@ -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
}

View File

@ -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 {
"<xs:schema xmlns:xs='http://www.w3.org/2001/XMLSchema'>"
+ "<xs:complexType name='t1'><xs:simpleContent><xs:extension base='\(base)'/></xs:simpleContent></xs:complexType>"
+ "<xs:complexType name='t2'><xs:simpleContent><xs:restriction base='t1'>\(facet)</xs:restriction></xs:simpleContent></xs:complexType>"
+ "</xs:schema>"
}
@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: "<xs:minLength value='1'/>"))
}
}
@Test("minLength on a string-based simpleContent restriction is valid")
func test_facetOnStringAccepted() throws {
_ = try compile(chain(base: "xs:string", facet: "<xs:minLength value='1'/>"))
}
@Test("maxInclusive on an int-based simpleContent restriction is valid")
func test_facetOnIntAccepted() throws {
_ = try compile(chain(base: "xs:int", facet: "<xs:maxInclusive value='5'/>"))
}
@Test("an anySimpleType extension with no facet restriction is valid")
func test_anySimpleTypeWithoutFacetAccepted() throws {
_ = try compile(
"<xs:schema xmlns:xs='http://www.w3.org/2001/XMLSchema'>"
+ "<xs:complexType name='t1'><xs:simpleContent><xs:extension base='xs:anySimpleType'/></xs:simpleContent></xs:complexType>"
+ "</xs:schema>",
)
}
}

View File

@ -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);