PureXML/Sources/Parsing/DoctypeScannerStrict.swift
Mihaela Mihaljevic 32d42410b9 feat(parsing): entity replacement-text well-formedness, fifth suite pass
Implement the replacement-text constraint at the point of reference: when a
general entity is included, its replacement (character references expanded
as at declaration time) must reparse as balanced content in isolation, via a
new EntityReplacementGrammar mini-parser. This rejects a bare or incomplete
reference ('&', '&#9' unterminated), tags spanning the entity boundary
('</foo><foo>'), a raw '<' or incomplete reference inside an embedded
attribute value, '<' followed by a non-name character, and a reserved-target
PI in the replacement, exactly the suite's semantics, including that an
UNREFERENCED entity may carry any value and that only the first declaration
of a name binds. Attribute-list defaults must reference already-declared
internal (not external/unparsed/forward-declared) entities with a
non-recursive chain. A CDATA section inside replacement text now shields its
content from reference expansion, fixing the last valid-side case.

W3C xmltest final standing: valid/sa 119/119, not-wf/sa 180/186, with the
six remaining deviations all deliberate and documented in the baseline:
internal-subset conditional/CDATA sections (063, 107) and parameter-entity
references (160-162) are features this package supports, and 141 expects the
1998 suite's name-character classes where this package implements XML 1.0
Fifth Edition's. Zero false positives across all 846 tests at every step.

Closes #120
2026-06-10 05:47:42 +02:00

212 lines
9.3 KiB
Swift

/// The strict XML 1.0 grammar for `<!ENTITY>` declarations, external
/// identifiers, and public-identifier literals, split from the scanner body to
/// keep it under the length caps. The scan is one well-formedness gate: any
/// deviation throws a located `ParseError`.
extension DTDScanner {
mutating func scanEntityDeclaration(_ reader: inout Reader) throws {
let mark = reader.mark
reader.consume("<!ENTITY")
guard reader.peek()?.isWhitespace == true else {
throw ParseError.invalidEntityDeclaration(mark)
}
reader.skipSpace()
let isParameter = reader.consume("%")
if isParameter {
guard reader.peek()?.isWhitespace == true else {
throw ParseError.invalidEntityDeclaration(mark)
}
reader.skipSpace()
}
let name = scanName(&reader)
guard !name.isEmpty else {
throw ParseError.invalidEntityDeclaration(mark)
}
guard reader.peek()?.isWhitespace == true else {
throw ParseError.invalidEntityDeclaration(mark)
}
reader.skipSpace()
if let value = scanLiteral(&reader) {
try scanInternalEntityTail(&reader, name: name, value: value, isParameter: isParameter, at: mark)
return
}
try scanExternalEntityTail(&reader, name: name, isParameter: isParameter, at: mark)
}
/// The tail of an internal entity: the literal already read, then `S? '>'`.
private mutating func scanInternalEntityTail(_ reader: inout Reader, name: String, value: String, isParameter: Bool, at mark: Mark) throws {
try validateEntityLiteral(value, at: mark)
reader.skipSpace()
guard reader.peek() == ">" else {
throw ParseError.invalidEntityDeclaration(mark)
}
skip(&reader, until: ">")
// The replacement-text well-formedness constraint binds at reference
// time (an unreferenced entity may carry a bad value), so the reparse
// happens in EntityDecoder when the entity is first included.
storeEntity(name: name, value: expandParameterReferences(value), isParameter: isParameter)
}
/// The tail of an external entity: the identifier, an optional `S NDATA Name`
/// (general entities only), then `S? '>'`.
private mutating func scanExternalEntityTail(_ reader: inout Reader, name: String, isParameter: Bool, at mark: Mark) throws {
guard let id = try parseStrictExternalID(&reader, requireSystem: true, at: mark) else {
throw ParseError.invalidEntityDeclaration(mark)
}
var notation: String?
if reader.peek()?.isWhitespace == true {
reader.skipSpace()
if reader.consume("NDATA") {
// NDATA is for unparsed general entities only.
guard !isParameter else {
throw ParseError.invalidEntityDeclaration(mark)
}
notation = notationName(&reader)
}
} else if reader.peek() != ">" {
throw ParseError.invalidEntityDeclaration(mark)
}
reader.skipSpace()
guard reader.peek() == ">" else {
throw ParseError.invalidEntityDeclaration(mark)
}
skip(&reader, until: ">")
storeExternalEntity(name: name, id: id, isParameter: isParameter, notation: notation)
}
/// Validates an entity value literal: every character must be an XML Char,
/// and every `&` must begin a complete reference (`&name;` or a character
/// reference); a bare ampersand is not well-formed.
func validateEntityLiteral(_ value: String, at mark: Mark) throws {
let characters = Array(value)
var index = 0
while index < characters.count {
let character = characters[index]
guard character.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isChar) else {
throw ParseError.invalidCharacter(mark)
}
if character == "&" {
var probe = index + 1
if probe < characters.count, characters[probe] == "#" { probe += 1 }
let bodyStart = probe
while probe < characters.count, characters[probe] != ";", !characters[probe].isWhitespace, characters[probe] != "&" {
probe += 1
}
guard probe > bodyStart, probe < characters.count, characters[probe] == ";" else {
throw ParseError.invalidReference("&", mark)
}
index = probe
}
index += 1
}
}
/// Parses an external identifier strictly: `SYSTEM S literal`, or
/// `PUBLIC S pubid-literal S system-literal` (the system literal is required
/// when `requireSystem` is true, as for entities and the DOCTYPE itself).
/// Public identifiers are checked against the `PubidChar` set.
func parseStrictExternalID(_ reader: inout Reader, requireSystem: Bool, at mark: Mark) throws -> ExternalID? {
if reader.consume("SYSTEM") {
guard reader.peek()?.isWhitespace == true else {
throw ParseError.invalidEntityDeclaration(mark)
}
reader.skipSpace()
guard let systemID = scanLiteral(&reader) else {
throw ParseError.invalidEntityDeclaration(mark)
}
return ExternalID(systemID: systemID)
}
if reader.consume("PUBLIC") {
return try parsePublicID(&reader, requireSystem: requireSystem, at: mark)
}
return nil
}
/// `PUBLIC S PubidLiteral (S SystemLiteral)` with the system literal
/// required for entities and the DOCTYPE, optional for notations.
private func parsePublicID(_ reader: inout Reader, requireSystem: Bool, at mark: Mark) throws -> ExternalID {
guard reader.peek()?.isWhitespace == true else {
throw ParseError.invalidEntityDeclaration(mark)
}
reader.skipSpace()
guard let publicID = scanLiteral(&reader) else {
throw ParseError.invalidEntityDeclaration(mark)
}
try validatePublicID(publicID, at: mark)
if requireSystem {
guard reader.peek()?.isWhitespace == true else {
throw ParseError.invalidEntityDeclaration(mark)
}
reader.skipSpace()
guard let systemID = scanLiteral(&reader) else {
throw ParseError.invalidEntityDeclaration(mark)
}
return ExternalID(publicID: publicID, systemID: systemID)
}
// Notations may carry a public identifier alone.
var systemID = ""
if reader.peek()?.isWhitespace == true {
reader.skipSpace()
if let literal = scanLiteral(&reader) { systemID = literal }
}
return ExternalID(publicID: publicID, systemID: systemID)
}
/// `PubidChar ::= #x20 | #xD | #xA | [a-zA-Z0-9] | [-'()+,./:=?;!*#@$_%]`
func validatePublicID(_ publicID: String, at mark: Mark) throws {
let allowed = Set("-'()+,./:=?;!*#@$_%")
for character in publicID {
let valid = character == " " || character == "\r" || character == "\n"
|| (character.isASCII && (character.isLetter || character.isNumber))
|| allowed.contains(character)
guard valid else {
throw ParseError.invalidPublicIdentifier(mark)
}
}
}
/// A default attribute value may reference only general entities that are
/// already declared and internal: an undeclared, forward-declared, external,
/// or unparsed entity in a default is not well-formed, and the referenced
/// chain must not recurse.
func validateDefaultValueReferences(_ value: String, at mark: Mark) throws {
try walkDefaultReferences(value, visiting: [], at: mark)
}
func walkDefaultReferences(_ value: String, visiting: Set<String>, at mark: Mark) throws {
let predefined: Set = ["amp", "lt", "gt", "quot", "apos"]
let characters = Array(value)
var index = 0
while index < characters.count {
guard characters[index] == "&" else {
index += 1
continue
}
var probe = index + 1
if probe < characters.count, characters[probe] == "#" {
while probe < characters.count, characters[probe] != ";" {
probe += 1
}
index = probe + 1
continue
}
var name = ""
while probe < characters.count, characters[probe] != ";" {
name.append(characters[probe])
probe += 1
}
index = probe + 1
guard !predefined.contains(name) else { continue }
guard !visiting.contains(name) else {
throw ParseError.recursiveEntity(name: name, mark)
}
guard doctype.unparsedEntities[name] == nil, doctype.externalEntities[name] == nil else {
throw ParseError.invalidAttributeListDeclaration(mark)
}
guard let replacement = doctype.entities[name] else {
throw ParseError.undefinedEntity(name: name, mark)
}
try walkDefaultReferences(replacement, visiting: visiting.union([name]), at: mark)
}
}
}