/// The strict XML 1.0 grammar for `` 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("'`. private mutating func scanInternalEntityTail(_ reader: inout Reader, name: String, value: String, isParameter: Bool, at mark: Mark) throws { try checkStrictSubsetReferences(value, at: mark) try validateEntityLiteral(value, at: mark) reader.skipSpace() guard reader.peek() == ">" else { throw ParseError.invalidEntityDeclaration(mark) } skip(&reader, until: ">") // Character references in an entity literal are expanded when the // declaration is parsed (4.4.5): `%zz;` stores a usable `%zz;`, // `<foo>` stores markup that content splicing reparses, and the // Appendix D double escape `&#38;` stores `&`, which the // content reparse turns into a literal ampersand. The replacement-text // well-formedness constraint still binds at reference time (an // unreferenced entity may carry a bad value). guard let replacement = PureXML.Parsing.EntityReplacementGrammar.expandCharacterReferences(value) else { throw ParseError.invalidEntityDeclaration(mark) } storeEntity(name: name, value: expandParameterReferences(replacement), 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, and `S Name` // must follow (production 76). guard !isParameter, reader.peek()?.isWhitespace == true else { throw ParseError.invalidEntityDeclaration(mark) } reader.skipSpace() notation = try scanStrictName(&reader, at: mark) } } 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, /// every `&` must begin a complete reference (`&name;` or a character /// reference), and every `%` must begin a complete parameter-entity /// reference (production 9 admits no bare `%` or `&` in an EntityValue). 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 == "&" || character == "%" { var probe = index + 1 let isCharacterReference = character == "&" && probe < characters.count && characters[probe] == "#" if isCharacterReference { probe += 1 } let bodyStart = probe let stops: Set = [";", "&", "%"] while probe < characters.count, !stops.contains(characters[probe]), !characters[probe].isWhitespace { probe += 1 } guard probe > bodyStart, probe < characters.count, characters[probe] == ";" else { throw ParseError.invalidReference(String(character), mark) } // An entity reference's body must be a Name: '&49;' is neither // a character reference nor a legal entity name (production 68). if !isCharacterReference { let body = String(characters[bodyStart ..< probe]) guard PureXML.Parsing.XMLCharacter.isValidName(body) else { throw ParseError.invalidReference(String(character) + body, mark) } } index = probe } index += 1 } } /// Scans a Name strictly: the first character must satisfy NameStartChar /// and the rest NameChar, so `-ge` or `.pe` is rejected where the lenient /// continuation-character scan would accept it. Entity and notation names /// are NCNames under namespaces, so a colon is also rejected. mutating func scanStrictName(_ reader: inout Reader, at mark: Mark) throws -> String { guard let first = reader.peek(), first.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isNameStart) else { throw ParseError.expectedName(mark) } let name = scanName(&reader) guard !name.contains(":") else { throw ParseError.namespaceConstraint(reason: "'\(name)' is not a legal NCName", mark) } return name } /// Removes a leading text declaration from an external entity's resolved /// text (4.3.1): the declaration is not part of the replacement text. Text /// without one passes through unchanged, including text whose ` Bool { guard let declared = declaration.version else { return true } return declared <= documentVersion } func strippingTextDeclaration(_ text: String) throws -> String { guard text.hasPrefix("'. var close = text.index(text.startIndex, offsetBy: 5) while close < text.endIndex { if text[close] == "?", text.index(after: close) < text.endIndex, text[text.index(after: close)] == ">" { break } close = text.index(after: close) } guard close < text.endIndex else { return text } let body = String(text[text.index(text.startIndex, offsetBy: 5) ..< close]) guard body.first?.isWhitespace == true, let declaration = PureXML.Parsing.XMLDeclaration.parseTextDeclaration(body) else { return text } guard textDeclarationVersionAllowed(declaration) else { throw ParseError.malformedDeclaration(.start) } return String(text[text.index(close, offsetBy: 2)...]) } /// Consumes the external subset's optional text declaration /// `` (production 77): unlike the /// document's XML declaration, the version is optional, the encoding is /// required, and `standalone` is not allowed. mutating func scanTextDeclaration(_ reader: inout Reader, at mark: Mark) throws { // Reader copies share their pull source, so look ahead in place: only // '' falls through to the // reserved-target check). guard reader.matches("") { guard let character = reader.advance() else { throw ParseError.malformedDeclaration(mark) } text.append(character) } reader.consume("?>") guard PureXML.Parsing.XMLDeclaration.parseTextDeclaration(text) != nil else { throw ParseError.malformedDeclaration(mark) } } /// 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, base: currentBase) } 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, base: currentBase) } // 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, base: currentBase) } /// `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. mutating func validateDefaultValueReferences(_ value: String, at mark: Mark) throws { try walkDefaultReferences(value, visiting: [], at: mark) } mutating func walkDefaultReferences(_ value: String, visiting: Set, 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 { // VC/WFC: Entity Declared, same split as parameter references. if entityDeclaredIsWellFormedness { throw ParseError.undefinedEntity(name: name, mark) } doctype.validityFindings.append(PureXML.Parsing.ValidityFinding("entity '&\(name);' in an attribute default is referenced but not declared")) return } try walkDefaultReferences(replacement, visiting: visiting.union([name]), at: mark) } } }