EntityExpander.expand rebuilt every reference-bearing text run one character at a time: result.append(character) per character, with a per-character charge against the amplification budget. A text node holding a single & was copied grapheme by grapheme, which is the bulk of the per-text-node cost in a document whose content uses entities. Scan to the next '&' or '<' and append the literal run in one operation, charging the budget by the run length. The decoded text, the CDATA-verbatim shielding, and the billion-laughs budget enforcement are unchanged: the same overrun throws at the same input, since charging a run of length L against budget B fails exactly when L > B, the same condition the per-character guard enforced. Also return early from recordReferenceContentFindings when there are no DTD content models (the common no-DTD case): it otherwise scanned each text run for '&' and rendered the enclosing element's qualified name before the model lookup that would fail anyway. Measured parse ~5% faster on the 20k-item generated corpus via a same-state A/B (9.06x to ~8.5x slower than libxml2). Verified by the W3C XML conformance corpora, the new EntityDecodeBulkTests six-case decode differential, the full suite, the Xalan XSLT gold-output corpus, and the WASM build, with an adversarial equivalence review of the rewrite.
145 lines
7.2 KiB
Swift
145 lines
7.2 KiB
Swift
/// The validity-aware side of reference handling: which entities a reference
|
|
/// may see (the standalone WFC), whether an undeclared reference is fatal
|
|
/// (production 68), and the errata E15 content findings. Split from the
|
|
/// reader body to keep it under the length caps.
|
|
extension PureXML.Parsing.EventReader {
|
|
typealias EntityDecoder = PureXML.Parsing.EntityDecoder
|
|
typealias Mark = PureXML.Parsing.Mark
|
|
|
|
/// The entity table visible to references. A standalone document must
|
|
/// not reference an entity declared outside its internal subset (WFC:
|
|
/// Entity Declared, 2.9), so external declarations are hidden and such
|
|
/// a reference reports as undeclared.
|
|
var referencableEntities: [String: String] {
|
|
guard xmlDeclaration?.standalone == true else { return documentType.entities }
|
|
return documentType.entities.filter { documentType.internalEntities.contains($0.key) }
|
|
}
|
|
|
|
/// Whether an undeclared entity reference is fatal (production 68):
|
|
/// it is a WFC when the document is standalone or could not have
|
|
/// external declarations, and a validity finding otherwise.
|
|
var undeclaredEntityIsFatal: Bool {
|
|
if xmlDeclaration?.standalone == true { return true }
|
|
return documentType.externalSubset == nil && documentType.parameterEntities.isEmpty
|
|
}
|
|
|
|
/// Decodes references in text or an attribute value, with undeclared
|
|
/// entities routed per `undeclaredEntityIsFatal`.
|
|
mutating func decodeReferences(_ raw: String, at mark: Mark) throws -> String {
|
|
guard undeclaredEntityIsFatal else {
|
|
var undeclared: [String] = []
|
|
let decoded = try EntityDecoder.decodeLenient(
|
|
raw,
|
|
entities: referencableEntities,
|
|
budget: &entityBudget,
|
|
at: mark,
|
|
undeclared: &undeclared,
|
|
)
|
|
for name in undeclared {
|
|
documentType.validityFindings.append(PureXML.Parsing.ValidityFinding("general entity '&\(name);' is referenced but not declared"))
|
|
}
|
|
return decoded
|
|
}
|
|
return try EntityDecoder.decode(raw, entities: referencableEntities, budget: &entityBudget, at: mark)
|
|
}
|
|
|
|
/// Whether the run contains a direct character reference, or references
|
|
/// an entity whose stored replacement does (the errata E15h shape: the
|
|
/// double-escaped reference surfaces when the replacement is reparsed).
|
|
mutating func referencesCharacter(_ raw: String) -> Bool {
|
|
if containsCharacterReference(raw) { return true }
|
|
let entities = referencableEntities
|
|
var index = raw.startIndex
|
|
while index < raw.endIndex, let amp = raw[index...].firstIndex(of: "&") {
|
|
guard let semicolon = raw[amp...].firstIndex(of: ";") else { return false }
|
|
let name = String(raw[raw.index(after: amp) ..< semicolon])
|
|
if let replacement = entities[name], containsCharacterReference(replacement) {
|
|
return true
|
|
}
|
|
index = raw.index(after: semicolon)
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Whether the text contains a direct character reference (Foundation-
|
|
/// free substring test).
|
|
func containsCharacterReference(_ raw: String) -> Bool {
|
|
var sawAmp = false
|
|
for character in raw {
|
|
if sawAmp, character == "#" { return true }
|
|
sawAmp = character == "&"
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Records the errata E15 finding for non-element content inside an
|
|
/// element declared EMPTY.
|
|
mutating func recordEmptyElementContent(_ what: String) {
|
|
guard let current = open.last,
|
|
let model = documentType.elementModels[current.localName] ?? documentType.elementModels[current.description],
|
|
model == "EMPTY"
|
|
else { return }
|
|
documentType.validityFindings.append(PureXML.Parsing.ValidityFinding(
|
|
"\(what) appears in the EMPTY element <\(current.description)>",
|
|
subject: current.description,
|
|
))
|
|
}
|
|
|
|
/// The errata E15 validity findings a text run can carry: any reference
|
|
/// inside an element declared EMPTY, and reference-derived whitespace
|
|
/// (which is character data, not ignorable whitespace) inside element
|
|
/// content.
|
|
mutating func recordReferenceContentFindings(raw: String, decoded: String) {
|
|
// Element-content reference findings exist only against a declared
|
|
// content model. With no DTD (the common case) there is nothing to
|
|
// check, so return before scanning `raw` for '&' or rendering the
|
|
// element's qualified name.
|
|
guard !documentType.elementModels.isEmpty else { return }
|
|
guard raw.contains("&"), let current = open.last,
|
|
let model = documentType.elementModels[current.localName] ?? documentType.elementModels[current.description]
|
|
else { return }
|
|
if model == "EMPTY" {
|
|
documentType.validityFindings.append(PureXML.Parsing.ValidityFinding(
|
|
"a reference appears in the EMPTY element <\(current.description)>",
|
|
subject: current.description,
|
|
))
|
|
return
|
|
}
|
|
// Errata E15: a direct character reference is character data even
|
|
// when it names whitespace; an entity reference whose replacement is
|
|
// whitespace is permitted in element content.
|
|
let isElementContent = model.hasPrefix("(") && !model.hasPrefix("(#PCDATA")
|
|
let isReferenceWhitespace = !decoded.isEmpty && decoded.allSatisfy(\.isWhitespace)
|
|
if isElementContent, isReferenceWhitespace, referencesCharacter(raw) {
|
|
documentType.validityFindings.append(PureXML.Parsing.ValidityFinding(
|
|
"character-reference whitespace in the element content of <\(current.description)>",
|
|
subject: current.description,
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Applies the DTD-declared tokenized normalization to namespace
|
|
/// declaration values before they bind: an `xmlns:b` declared NMTOKEN in
|
|
/// the DTD binds its collapsed value, so ' urn:x ' and 'urn:x' are the
|
|
/// same namespace name (eduni ns 1.0/012).
|
|
mutating func normalizedBindings(
|
|
element: PureXML.Model.QualifiedName,
|
|
attributes: [PureXML.Model.Attribute],
|
|
) -> [PureXML.Model.Attribute] {
|
|
let hasBinding = attributes.contains { $0.name.prefix == "xmlns" || ($0.name.prefix == nil && $0.name.localName == "xmlns") }
|
|
guard hasBinding,
|
|
let body = documentType.attributeLists[element.description] ?? documentType.attributeLists[element.localName]
|
|
else { return attributes }
|
|
let declarations = PureXML.Validation.AttributeListParser.parse(body)
|
|
return attributes.map { attribute in
|
|
let isBinding = attribute.name.prefix == "xmlns" || (attribute.name.prefix == nil && attribute.name.localName == "xmlns")
|
|
guard isBinding,
|
|
let declaration = declarations.first(where: { $0.name == attribute.name.description })
|
|
else { return attribute }
|
|
if case .cdata = declaration.type { return attribute }
|
|
let collapsed = attribute.value.split(separator: " ").joined(separator: " ")
|
|
return PureXML.Model.Attribute(name: attribute.name, value: collapsed)
|
|
}
|
|
}
|
|
}
|