PureXML/Sources/Parsing/DoctypeScannerEntities.swift
Mihaela Mihaljevic d0175d5ca0 refactor(validation): located structured validity findings
DocumentType.validityFindings carries ValidityFinding values (reason
plus subject) instead of strings, DTDSchema.declarationErrors holds
ready ValidationErrors, and every declaration-level finding renders at
its declaration's coding path (x, r/@a) instead of at the document
root, matching the XSD consistency-errors precedent. Texts unchanged.
2026-06-10 10:45:17 +02:00

123 lines
6.0 KiB
Swift

/// The entity side of the DTD scan: storing declarations, resolving external
/// entities through the injected resolver, expanding parameter-entity
/// references, and the bare `%name;` declaration-stream injection. Split from
/// the scanner body to keep it under the length caps.
extension DTDScanner {
/// 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.
mutating func resolveExternalEntities() {
for (name, id) in doctype.externalEntities where doctype.entities[name] == nil {
if let text = resolver.resolveEntity(name, baseResolved(id)) {
// An external parsed entity may begin with a text declaration,
// which is not part of its replacement text (4.3.1).
doctype.entities[name] = try? strippingTextDeclaration(text)
}
}
}
/// The identifier with its base applied into the system ID, so a resolver
/// that only reads `systemID` still sees the resolved path.
func baseResolved(_ id: ExternalID) -> ExternalID {
var resolved = id
resolved.systemID = id.resolvedSystemID
resolved.base = nil
return resolved
}
/// 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.
mutating func scanParameterReference(_ reader: inout Reader, depth: Int, at mark: Mark) throws {
let refMark = reader.mark
reader.consume("%")
let name = scanName(&reader)
// PEReference is '%' Name ';' exactly: no space after '%' (an empty
// name) and the semicolon must follow the name directly.
guard !name.isEmpty, reader.consume(";") else {
throw ParseError.invalidReference("%\(name)", refMark)
}
guard let replacement = doctype.parameterEntities[name] else {
// A declared external PE the resolver refused is not undeclared,
// the processor just did not read it.
guard !unresolvedParameterEntities.contains(name) else { return }
// VC/WFC: Entity Declared (also covers a reference before the
// declaration: declarations bind in document order).
if entityDeclaredIsWellFormedness {
throw ParseError.undefinedEntity(name: name, refMark)
}
doctype.validityFindings.append(PureXML.Parsing.ValidityFinding("parameter entity '%\(name);' is referenced but not declared"))
return
}
guard depth < maxDepth else {
return
}
guard parameterBudget >= replacement.count else {
return
}
parameterBudget -= replacement.count
var sub = Reader(replacement)
// Identifiers declared inside the replacement resolve against the
// parameter entity's own URI (per-entity base, RFC 3986).
let outerBase = currentBase
if let base = parameterEntityBases[name] {
currentBase = base
}
defer { currentBase = outerBase }
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 {
if let text = resolver.resolveExternalSubset(baseResolved(id)) {
if let stripped = try? strippingTextDeclaration(text) {
doctype.parameterEntities[name] = expandParameterReferences(stripped)
parameterEntityBases[name] = id.resolvedSystemID
} else {
unresolvedParameterEntities.insert(name)
}
} else {
unresolvedParameterEntities.insert(name)
}
}
} else if doctype.externalEntities[name] == nil {
doctype.externalEntities[name] = id
}
}
/// `<!NOTATION S Name S (ExternalID | PublicID) S? '>'` (production 82):
/// the identifier is required and nothing may follow it but whitespace.
/// 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
}
}