PureXML/Tests/DTDSubsetStrictnessTests.swift
Mihaela Mihaljevic 380160cc2b refactor(validation): decompose XSD instance validation into composable rules
The two monolithic XSD content/identity rules only satisfied the OpenAPIKit
validation idiom at the wrong granularity: no per-constraint removability,
no isolable failing/near-miss tests. Split them into 32 named rules, each
finding tagged at its origin and cached per XSDContext so the walk still
runs once per document (the #101 decision against duplicating XSD's
recursive type resolution still holds). Wired the streaming path's ID/IDREF
and identity checks through the same framework instead of calling the
validators imperatively, and added the unwrapAndLookup combinator the rule
already named as required.

Also includes the mandated repo-wide swiftformat/swiftlint pass.
2026-07-12 14:12:17 +02:00

158 lines
7.8 KiB
Swift

import Testing
@testable import PureXML
/// The DTD-subset strictness landed with the OASIS/NIST conformance pass:
/// unknown or malformed markup declarations, the exact `%name;` parameter-
/// reference grammar, strict declaration names, NDATA rules, notation tails,
/// conditional-section keywords and balance, external-subset error
/// propagation with text declarations, and PI target separation.
@Suite("DTD subset strictness")
struct DTDSubsetStrictnessTests {
private func limits() -> PureXML.Parsing.Limits {
PureXML.Parsing.Limits(allowDoctype: true)
}
private func parses(_ xml: String) -> Bool {
(try? PureXML.parse(xml, limits: limits())) != nil
}
private func parses(_ xml: String, externalSubset: String) -> Bool {
let resolver = PureXML.Parsing.EntityResolver(
resolveEntity: { _, _ in nil },
resolveExternalSubset: { _ in externalSubset },
)
return (try? PureXML.parse(xml, limits: limits(), resolver: resolver)) != nil
}
private let externalHost = "<!DOCTYPE doc SYSTEM \"sub.dtd\">\n<doc/>"
@Test("Unknown, lowercase, or space-separated declaration keywords are rejected")
func test_declarationKeywords() {
#expect(!parses("<!DOCTYPE doc [<!ELEMENT doc EMPTY><!DUNNO junk>]>\n<doc/>"))
#expect(!parses("<!DOCTYPE doc [<!element doc EMPTY>]>\n<doc/>"))
#expect(!parses("<!DOCTYPE doc [<! ENTITY ge \"v\">]>\n<doc/>"))
#expect(parses("<!DOCTYPE doc [<!ELEMENT doc EMPTY>]>\n<doc/>"))
}
@Test("A parameter-entity reference is '%' Name ';' exactly")
func test_parameterReferenceGrammar() {
let declare = "<!ELEMENT doc EMPTY><!ENTITY % pe \"<!---->\">"
#expect(!parses("<!DOCTYPE doc [\(declare)%pe<!---->]>\n<doc/>"))
#expect(!parses("<!DOCTYPE doc [\(declare)% pe;]>\n<doc/>"))
#expect(!parses("<!DOCTYPE doc [\(declare)%pe ;]>\n<doc/>"))
#expect(parses("<!DOCTYPE doc [\(declare)%pe;]>\n<doc/>"))
}
@Test("Entity and parameter-entity names must start with a name-start character")
func test_strictEntityNames() {
#expect(!parses("<!DOCTYPE doc [<!ELEMENT doc EMPTY><!ENTITY -ge \"v\">]>\n<doc/>"))
#expect(!parses("<!DOCTYPE doc [<!ELEMENT doc EMPTY><!ENTITY % .pe \"v\">]>\n<doc/>"))
#expect(parses("<!DOCTYPE doc [<!ELEMENT doc EMPTY><!ENTITY ge \"v\">]>\n<doc/>"))
}
@Test("NDATA requires whitespace and a strict notation name")
func test_ndataNames() {
let notation = "<!NOTATION n SYSTEM \"x\">"
#expect(!parses("<!DOCTYPE doc [<!ELEMENT doc EMPTY>\(notation)<!ENTITY ge SYSTEM \"e\" NDATA>]>\n<doc/>"))
#expect(!parses("<!DOCTYPE doc [<!ELEMENT doc EMPTY>\(notation)<!ENTITY ge SYSTEM \"e\" NDATA -n>]>\n<doc/>"))
#expect(parses("<!DOCTYPE doc [<!ELEMENT doc EMPTY>\(notation)<!ENTITY ge SYSTEM \"e\" NDATA n>]>\n<doc/>"))
}
@Test("A notation declaration requires its identifier and a clean tail")
func test_notationTail() {
#expect(!parses("<!DOCTYPE doc [<!ELEMENT doc EMPTY><!NOTATION n SYSTEM \"\"\">]>\n<doc/>"))
#expect(!parses("<!DOCTYPE doc [<!ELEMENT doc EMPTY><!NOTATION n>]>\n<doc/>"))
#expect(parses("<!DOCTYPE doc [<!ELEMENT doc EMPTY><!NOTATION n PUBLIC \"p\">]>\n<doc/>"))
}
@Test("An entity value literal admits no bare '%' or '&'")
func test_entityLiteralReferences() {
#expect(!parses(externalHost, externalSubset: "<!ELEMENT doc EMPTY><!ENTITY % e \"asdf%\">"))
#expect(!parses(externalHost, externalSubset: "<!ELEMENT doc EMPTY><!ENTITY % e \"asdf&\">"))
#expect(parses(externalHost, externalSubset: "<!ELEMENT doc EMPTY><!ENTITY % e \"a%pe;b\">"))
}
@Test("Conditional-section keywords are case-sensitive INCLUDE or IGNORE and must balance")
func test_conditionalSections() {
#expect(!parses(externalHost, externalSubset: "<!ELEMENT doc EMPTY><![TEMP[<!ATTLIST doc a CDATA #IMPLIED>]]>"))
#expect(!parses(externalHost, externalSubset: "<![include[<!ELEMENT doc EMPTY>]]>"))
#expect(!parses(externalHost, externalSubset: "<!ELEMENT doc EMPTY><![ignore[]]>"))
#expect(!parses(externalHost, externalSubset: "<![INCLUDE[<!ELEMENT doc EMPTY>] ]>"))
#expect(!parses(externalHost, externalSubset: "<!ELEMENT doc EMPTY><![IGNORE[<![]]>"))
#expect(!parses(externalHost, externalSubset: "<!ELEMENT doc EMPTY><![IGNORE[ x ]]>]]>"))
#expect(parses(externalHost, externalSubset: "<![INCLUDE[<!ELEMENT doc EMPTY>]]>"))
#expect(parses(externalHost, externalSubset: "<!ELEMENT doc EMPTY><![IGNORE[ <![ x ]]> ]]>"))
}
@Test("External-subset grammar violations reject the document")
func test_externalSubsetPropagates() {
#expect(!parses(externalHost, externalSubset: "<!DOCTYPE doc [<!ELEMENT doc EMPTY>]>"))
#expect(parses(externalHost, externalSubset: "<!ELEMENT doc EMPTY>"))
}
@Test("A text declaration allows version and requires encoding, never standalone")
func test_textDeclaration() {
#expect(parses(externalHost, externalSubset: "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!ELEMENT doc EMPTY>"))
#expect(parses(externalHost, externalSubset: "<?xml encoding=\"UTF-8\"?>\n<!ELEMENT doc EMPTY>"))
#expect(!parses(externalHost, externalSubset: "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<!ELEMENT doc EMPTY>"))
#expect(!parses(externalHost, externalSubset: "<?xml version=\"1.0\"?>\n<!ELEMENT doc EMPTY>"))
}
@Test("A processing-instruction target must be separated from its data")
func test_processingInstructionSeparation() {
#expect(!parses("<?pitarget+++?>\n<doc/>"))
#expect(parses("<?pitarget +++?>\n<doc/>"))
#expect(parses("<?pitarget?>\n<doc/>"))
}
@Test("The internal subset admits only declarations, PE references, and whitespace")
func test_subsetJunk() {
#expect(!parses("<!DOCTYPE doc [<!ELEMENT doc EMPTY> junk ]>\n<doc/>"))
#expect(!parses(externalHost, externalSubset: "<!ELEMENT doc EMPTY> ]]>"))
}
@Test("A PI target inside the subset must be separated from its data")
func test_subsetPISeparation() {
#expect(!parses("<!DOCTYPE doc [<!ELEMENT doc EMPTY><?bad-pi+?>]>\n<doc/>"))
#expect(parses("<!DOCTYPE doc [<!ELEMENT doc EMPTY><?good-pi data?>]>\n<doc/>"))
}
@Test("Character references in a PE literal are expanded at declaration (Appendix D)")
func test_parameterEntityCharacterReferences() {
// The spec's tricky example: %xx; expands to %zz; which declares tricky.
let xml = """
<!DOCTYPE test [
<!ELEMENT test (#PCDATA) >
<!ENTITY % xx '&#37;zz;'>
<!ENTITY % zz '&#60;!ENTITY tricky "error-prone" >' >
%xx;
]>
<test>This sample shows a &tricky; method.</test>
"""
let node = try? PureXML.parse(xml, limits: limits())
#expect(node != nil)
}
@Test("An external entity's text declaration is not part of its replacement")
func test_externalEntityTextDeclaration() {
let xml = "<!DOCTYPE root [<!ELEMENT root ANY><!ENTITY outside SYSTEM \"o.ent\">]>\n<root>&outside;</root>"
let resolver = PureXML.Parsing.EntityResolver(
resolveEntity: { _, _ in "<?xml encoding=\"UTF-8\"?>ok" },
resolveExternalSubset: { _ in nil },
)
let node = try? PureXML.parse(xml, limits: limits(), resolver: resolver)
#expect(node != nil)
// The declaration must be stripped, not included as text.
guard let node, case let .document(children) = node,
case let .element(root)? = children.first(where: { $0.element != nil })
else { return }
#expect(root.children.allSatisfy { child in
if case let .text(value) = child {
value == "ok"
} else {
false
}
})
}
}