PureXML/Sources/Parsing/DoctypeScanner.swift
Mihaela Mihaljevic dd0ff5d086 feat(parsing): fourth W3C-suite strictness pass, declaration grammar
Make the DTD declaration grammar strict: <!ENTITY> requires its whitespace
(after the keyword, around %, after the name), a real name (not #DEFAULT),
nothing but '>' after the value, NDATA preceded by whitespace and forbidden
on parameter entities; entity-value literals are validated (XML Chars only,
every & a complete reference); SYSTEM/PUBLIC external identifiers parse
strictly (PUBLIC needs both literals for entities and the DOCTYPE, public-only
stays legal for notations) with PubidChar validation; <!ATTLIST> bodies are
validated against XML 1.0 productions 52-60 via a new DTDAttListGrammar
(spacing, attribute types, enumerations, quoted defaults); the reserved xml
PI target is rejected inside the subset. Also: strict UTF-8 decoding (no
silent replacement of malformed sequences), XML Char checks in attribute
values and CDATA, the declaration's version/encoding value grammar, and
required whitespace between pseudo-attributes. The strict machinery lives in
DoctypeScannerStrict/DTDAttListGrammar to stay under the length caps.

W3C xmltest: not-wf/sa 162/186 rejected (from 129; 80 at first contact),
valid/sa 118/119, zero false positives across all 846 tests at every step.
The remaining 24-case baseline is exactly: the entity replacement-text class
(19) and the deliberate internal-subset extensions this package supports as
features (conditional sections, PEs in the internal subset: 5).

Part of #120, #105
2026-06-10 05:34:11 +02:00

377 lines
15 KiB
Swift

extension PureXML.Parsing {
/// Consumes a `<!DOCTYPE ...>` declaration.
///
/// When DTD processing is disabled (the default), the whole declaration is
/// refused, which keeps the XXE and entity-expansion threat classes closed.
/// When enabled, it honors internal general entities, internal parameter
/// entities (`<!ENTITY % name "value">`, expanded within the subset), element
/// and attribute-list declarations, and records external entity and external
/// subset identifiers. External text is loaded only through an injected
/// ``EntityResolver``; with the default refusing resolver nothing external is
/// read. Thin wrapper over the file-scope ``DTDScanner`` state machine.
enum DoctypeScanner {
static func scan(
_ reader: inout Reader,
limits: Limits,
resolver: EntityResolver = .refusing,
) throws -> DocumentType {
let mark = reader.mark
guard limits.allowDoctype else {
throw ParseError.unsupportedDoctype(mark)
}
var scanner = DTDScanner(limits: limits, resolver: resolver)
return try scanner.scan(&reader, at: mark)
}
}
}
/// The mutable state of one DTD scan: the document type being built and the
/// remaining parameter-entity expansion budget. An internal detail of
/// ``PureXML/Parsing/DoctypeScanner``; the strict declaration grammar lives in
/// DoctypeScannerStrict.swift.
struct DTDScanner {
typealias Reader = PureXML.Parsing.Reader
typealias Mark = PureXML.Parsing.Mark
typealias ParseError = PureXML.Parsing.ParseError
typealias ExternalID = PureXML.Parsing.ExternalID
let limits: PureXML.Parsing.Limits
let resolver: PureXML.Parsing.EntityResolver
var doctype = PureXML.Parsing.DocumentType()
var parameterBudget: Int
/// Bounds parameter-entity injection recursion (modularized DTDs nest, but
/// only a little); deeper references are ignored rather than trapping.
private let maxDepth = 40
init(limits: PureXML.Parsing.Limits, resolver: PureXML.Parsing.EntityResolver) {
self.limits = limits
self.resolver = resolver
parameterBudget = limits.maxEntityExpansion
}
mutating func scan(_ reader: inout Reader, at mark: Mark) throws -> PureXML.Parsing.DocumentType {
reader.consume("<!DOCTYPE")
reader.skipSpace()
_ = scanName(&reader)
reader.skipSpace()
doctype.externalSubset = try parseStrictExternalID(&reader, requireSystem: true, at: mark)
reader.skipSpace()
if reader.consume("[") {
try scanDeclarations(&reader, depth: 0, terminatedByBracket: true, at: mark)
}
reader.skipSpace()
guard reader.consume(">") else {
throw ParseError.unterminatedTag(mark)
}
loadExternalSubset(at: mark)
resolveExternalEntities()
return doctype
}
/// Folds declared external general entities into the general-entity table by
/// asking the resolver for their replacement text. A refused (nil) entity
/// stays undeclared, so a reference to it errors and the default refusing
/// resolver keeps XXE closed.
private mutating func resolveExternalEntities() {
for (name, id) in doctype.externalEntities where doctype.entities[name] == nil {
if let text = resolver.resolveEntity(name, id) {
doctype.entities[name] = text
}
}
}
/// Loads the external subset through the resolver, if one is configured and
/// returns text. External declarations never override internal ones (the
/// internal subset is scanned first and wins).
private mutating func loadExternalSubset(at mark: Mark) {
guard let id = doctype.externalSubset, let text = resolver.resolveExternalSubset(id) else {
return
}
var sub = Reader(text)
try? scanDeclarations(&sub, depth: 1, terminatedByBracket: false, at: mark)
}
private mutating func scanDeclarations(
_ reader: inout Reader,
depth: Int,
terminatedByBracket: Bool,
at mark: Mark,
) throws {
while true {
reader.skipSpace()
guard let character = reader.peek() else {
if terminatedByBracket {
throw ParseError.unsupportedDoctype(mark)
}
return
}
if terminatedByBracket, reader.consume("]") {
return
}
if character == "%" {
try scanParameterReference(&reader, depth: depth, at: mark)
} else if reader.matches("<![") {
try scanConditionalSection(&reader, depth: depth, at: mark)
} else {
try scanMarkupDeclaration(&reader)
}
}
}
/// Processes a conditional section `<![INCLUDE[ … ]]>` or `<![IGNORE[ … ]]>`.
/// The keyword may be a parameter-entity reference, so it is parameter-expanded
/// first. An INCLUDE section's declarations are scanned; an IGNORE section's
/// body is discarded. Nested sections are handled by re-scanning the body.
private mutating func scanConditionalSection(_ reader: inout Reader, depth: Int, at mark: Mark) throws {
reader.consume("<![")
var keywordRaw = ""
while let character = reader.peek(), character != "[" {
keywordRaw.append(character)
reader.advance()
}
reader.consume("[")
let body = readConditionalBody(&reader)
if expandParameterReferences(keywordRaw).trimmingXMLWhitespace() == "INCLUDE", depth < maxDepth {
var sub = Reader(body)
try scanDeclarations(&sub, depth: depth + 1, terminatedByBracket: false, at: mark)
}
}
/// Reads the body of a conditional section up to its matching `]]>`, keeping
/// any nested `<![ … ]]>` sections intact so an INCLUDE can re-scan them.
private mutating func readConditionalBody(_ reader: inout Reader) -> String {
var body = ""
var nesting = 1
while reader.peek() != nil {
if reader.matches("<![") {
nesting += 1
reader.consume("<![")
body += "<!["
} else if reader.matches("]]>") {
reader.consume("]]>")
nesting -= 1
if nesting == 0 { break }
body += "]]>"
} else if let character = reader.peek() {
body.append(character)
reader.advance()
}
}
return body
}
private mutating func scanMarkupDeclaration(_ reader: inout Reader) throws {
if reader.matches("<!ENTITY") {
try scanEntityDeclaration(&reader)
} else if reader.matches("<!ELEMENT") {
try scanElementDeclaration(&reader)
} else if reader.matches("<!ATTLIST") {
try scanAttributeListDeclaration(&reader)
} else if reader.matches("<!NOTATION") {
try scanNotationDeclaration(&reader)
} else if reader.matches("<!--") {
skip(&reader, until: "-->")
} else if reader.matches("<?") {
// The reserved target 'xml' may not appear as a PI in the subset.
let mark = reader.mark
reader.consume("<?")
let target = scanName(&reader)
if target.lowercased() == "xml" {
throw ParseError.reservedProcessingInstructionTarget(mark)
}
skip(&reader, until: "?>")
} else if reader.matches("<!") {
skip(&reader, until: ">")
} else {
reader.advance()
}
}
/// Handles a bare `%name;` between declarations by injecting the parameter
/// entity's replacement text and scanning its declarations. Bounded by depth
/// and the expansion budget.
private mutating func scanParameterReference(_ reader: inout Reader, depth: Int, at mark: Mark) throws {
reader.consume("%")
let name = scanName(&reader)
reader.consume(";")
guard depth < maxDepth, let replacement = doctype.parameterEntities[name] else {
return
}
guard parameterBudget >= replacement.count else {
return
}
parameterBudget -= replacement.count
var sub = Reader(replacement)
try scanDeclarations(&sub, depth: depth + 1, terminatedByBracket: false, at: mark)
}
/// Files an external entity declaration by kind: an `NDATA` entity is unparsed
/// (recorded with its notation); an external general entity records its
/// identifier for the resolver; an external parameter entity is loaded through
/// the resolver so its replacement text is available for `%name;` expansion
/// (the default refusing resolver loads nothing, keeping XXE closed).
mutating func storeExternalEntity(name: String, id: ExternalID, isParameter: Bool, notation: String?) {
if let notation, !notation.isEmpty, !isParameter {
if doctype.unparsedEntities[name] == nil {
doctype.unparsedEntities[name] = PureXML.Parsing.UnparsedEntity(id: id, notation: notation)
}
} else if isParameter {
if doctype.parameterEntities[name] == nil, let text = resolver.resolveExternalSubset(id) {
doctype.parameterEntities[name] = expandParameterReferences(text)
}
} else if doctype.externalEntities[name] == nil {
doctype.externalEntities[name] = id
}
}
mutating func notationName(_ reader: inout Reader) -> String {
reader.skipSpace()
return scanName(&reader)
}
private mutating func scanNotationDeclaration(_ reader: inout Reader) throws {
let mark = reader.mark
reader.consume("<!NOTATION")
reader.skipSpace()
let name = scanName(&reader)
reader.skipSpace()
let id = try parseStrictExternalID(&reader, requireSystem: false, at: mark)
skip(&reader, until: ">")
if !name.isEmpty, let id, doctype.notations[name] == nil {
doctype.notations[name] = id
}
}
mutating func storeEntity(name: String, value: String, isParameter: Bool) {
guard !name.isEmpty else { return }
if isParameter {
if doctype.parameterEntities[name] == nil {
doctype.parameterEntities[name] = value
}
} else if doctype.entities[name] == nil {
doctype.entities[name] = value
}
}
private mutating func scanElementDeclaration(_ reader: inout Reader) throws {
let mark = reader.mark
reader.consume("<!ELEMENT")
guard reader.peek()?.isWhitespace == true else {
throw PureXML.Parsing.ParseError.invalidContentModel(element: "", mark)
}
reader.skipSpace()
let name = scanName(&reader)
guard reader.peek()?.isWhitespace == true else {
throw PureXML.Parsing.ParseError.invalidContentModel(element: name, mark)
}
reader.skipSpace()
let model = expandParameterReferences(readUntilClose(&reader)).trimmingXMLWhitespace()
guard PureXML.Parsing.DTDContentModelGrammar.isValid(model) else {
throw PureXML.Parsing.ParseError.invalidContentModel(element: name, mark)
}
guard !name.isEmpty, doctype.elementModels[name] == nil else { return }
doctype.elementModels[name] = model
}
private mutating func scanAttributeListDeclaration(_ reader: inout Reader) throws {
let mark = reader.mark
reader.consume("<!ATTLIST")
guard reader.peek()?.isWhitespace == true else {
throw ParseError.invalidAttributeListDeclaration(mark)
}
reader.skipSpace()
let name = scanName(&reader)
guard !name.isEmpty else {
throw ParseError.invalidAttributeListDeclaration(mark)
}
let body = expandParameterReferences(readUntilClose(&reader))
guard PureXML.Parsing.DTDAttListGrammar.isValid(body) else {
throw ParseError.invalidAttributeListDeclaration(mark)
}
let trimmed = body.trimmingXMLWhitespace()
if let existing = doctype.attributeLists[name] {
doctype.attributeLists[name] = "\(existing) \(trimmed)"
} else {
doctype.attributeLists[name] = trimmed
}
}
}
extension DTDScanner {
/// Replaces `%name;` references with their (already-expanded) parameter-entity
/// values. Undefined references are left literal. A single forward pass is
/// enough because each value was expanded against the entities defined before
/// it, so no reference can reach a later definition.
func expandParameterReferences(_ raw: String) -> String {
guard raw.contains("%") else { return raw }
var result = ""
var index = raw.startIndex
while index < raw.endIndex {
let character = raw[index]
guard character == "%", let semicolon = raw[index...].firstIndex(of: ";") else {
result.append(character)
index = raw.index(after: index)
continue
}
let name = String(raw[raw.index(after: index) ..< semicolon])
result += doctype.parameterEntities[name] ?? "%\(name);"
index = raw.index(after: semicolon)
}
return result
}
private func parseExternalID(_ reader: inout Reader) -> ExternalID? {
if reader.consume("SYSTEM") {
reader.skipSpace()
guard let systemID = scanLiteral(&reader) else { return nil }
return ExternalID(systemID: systemID)
}
if reader.consume("PUBLIC") {
reader.skipSpace()
guard let publicID = scanLiteral(&reader) else { return nil }
reader.skipSpace()
let systemID = scanLiteral(&reader) ?? ""
return ExternalID(publicID: publicID, systemID: systemID)
}
return nil
}
func scanLiteral(_ reader: inout Reader) -> String? {
guard let quote = reader.peek(), quote == "\"" || quote == "'" else { return nil }
reader.advance()
var value = ""
while let character = reader.peek(), character != quote {
value.append(character)
reader.advance()
}
reader.consume(String(quote))
return value
}
private func readUntilClose(_ reader: inout Reader) -> String {
var text = ""
while let character = reader.peek(), character != ">" {
text.append(character)
reader.advance()
}
reader.consume(">")
return text
}
func scanName(_ reader: inout Reader) -> String {
var name = ""
while let character = reader.peek(), character.isXMLNameContinuation {
name.append(character)
reader.advance()
}
return name
}
func skip(_ reader: inout Reader, until terminator: String) {
while reader.peek() != nil, !reader.matches(terminator) {
reader.advance()
}
reader.consume(terminator)
}
}