Skip to content
macOSDeep Dive Published Updated 7 min readViews unavailable

XMLParser on macOS: Streaming Delegates, Text Accumulation, and External Entity Policy

Parse XML on macOS with explicit delegate state, split-text handling, external entity restrictions, resource limits, and all-or-nothing result publication.

Foundation’s XMLParser reports document events to a delegate instead of building an application object graph for the caller. That makes it useful for imports and bounded extraction, but the delegate must supply the document grammar, state transitions, accumulation rules, and acceptance policy. A callback occurring successfully is not proof that the whole document is valid or that a partially built result should become visible.

The two most consequential boundaries are split character content and external entity resolution. A robust implementation handles both deliberately, then adds application-level resource limits and transactional publication. This article concerns Foundation XML parsing on macOS, not a universal secure-parser recipe or XML schema validator.

Streaming describes callbacks, not unlimited capacity

An event-oriented interface can avoid retaining a complete XML tree. However, XMLParser(data:) still receives a complete Data value that the application already holds. Collecting every callback into an unbounded string or array also removes any memory advantage of incremental processing.

For an import, define maximum input bytes, nesting depth, accepted record count, and accumulated text length before parsing. Enforce transport or file-size limits before loading a large document, and enforce grammar limits in the delegate. An input-stream initializer can change how input is supplied, but it does not make an unbounded delegate safe or prove constant memory use.

Keep the parser and delegate local to one parse operation. Apple’s class documentation permits use from different threads when a given parser instance is used only on one thread. It does not justify calling methods concurrently on the same active instance or sharing a mutable delegate between independent parses.

Accumulate text until the element completes

Apple explicitly documents that foundCharacters may report only part of an element’s text, with several callbacks for one element. Assigning the most recent callback into a property silently loses earlier chunks. Append according to the application’s grammar and finalize only at the relevant end-element event.

The following small delegate accepts a root containing text-only item elements. It rejects nested elements inside an item and aborts when its application limits are exceeded. It keeps whitespace in item content; trimming would be a separate schema decision.

import Foundation

final class ItemImport: NSObject, XMLParserDelegate {
    private var path: [String] = []
    private var text = ""
    private(set) var items: [String] = []
    private(set) var rejected = false

    private func reject(_ parser: XMLParser) {
        rejected = true
        parser.abortParsing()
    }

    func parser(_ parser: XMLParser, didStartElement name: String,
                namespaceURI: String?, qualifiedName: String?,
                attributes: [String: String]) {
        guard attributes.isEmpty else { reject(parser); return }
        let allowed = (path.isEmpty && name == "items") ||
            (path == ["items"] && name == "item")
        guard allowed else { reject(parser); return }
        path.append(name)
        if name == "item" { text = "" }
    }

    func parser(_ parser: XMLParser, foundCharacters chunk: String) {
        if path == ["items", "item"] {
            guard chunk.utf8.count <= 4096 - text.utf8.count else {
                reject(parser); return
            }
            text.append(chunk)
        } else if !chunk.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
            reject(parser)
        }
    }

    func parser(_ parser: XMLParser, didEndElement name: String,
                namespaceURI: String?, qualifiedName: String?) {
        guard path.last == name else { reject(parser); return }
        if name == "item" {
            guard items.count < 100 else { reject(parser); return }
            items.append(text)
        }
        path.removeLast()
    }

    func parser(_ parser: XMLParser, foundCDATA data: Data) {
        // This deliberately restricted import does not accept CDATA.
        reject(parser)
    }
}

let input = Data("<items><item>alpha &amp; beta</item></items>".utf8)
precondition(input.count <= 64 * 1024)
let delegate = ItemImport()
let parser = XMLParser(data: input)
parser.delegate = delegate
parser.shouldResolveExternalEntities = false
parser.externalEntityResolvingPolicy = .never
let parsed = parser.parse()
precondition(parsed && !delegate.rejected)
precondition(delegate.items == ["alpha & beta"])

This example demonstrates a narrow import contract, not complete XML feature rejection. It ignores comments and does not enforce every possible document declaration. The input byte limit is shown as a fixture precondition; a real importer should return a normal validation error rather than terminate when a user supplies an oversized file.

The text budget uses UTF-8 bytes for application storage accounting. It is not a count of Unicode characters or a limit on the parser’s internal processing work. Keeping the unit named prevents a future maintainer from replacing it with a different count and unintentionally changing the accepted size.

External entity policy is an explicit trust decision

shouldResolveExternalEntities defaults to false. Set it explicitly when parsing input whose origin is outside the application’s trust boundary. Apple’s documentation warns that enabling external resolution can cause network or disk I/O to load an external DTD.

Also configure externalEntityResolvingPolicy deliberately. .never expresses that this parser should not resolve external entities. Do not interpret a no-network policy as no-filesystem-access policy: the names describe different boundaries. If a product genuinely requires external resources, use an explicit reviewed policy, allowlist, and resolver contract instead of enabling broad resolution for convenience.

Do not add a delegate resolver that fetches an arbitrary systemID supplied by the document. Doing so can reintroduce application-side network or file access regardless of the intended parsing policy. A resolver’s returned bytes also need limits and provenance. A successful request to a URL is not evidence that its content belongs to the imported document’s trusted schema.

External entity restrictions are not proof that every DTD feature, internal entity expansion, or resource-exhaustion path has been eliminated. If the format forbids DTDs or other XML features, enforce that requirement using an appropriate supported parsing/validation boundary and test it. A regex scan of raw bytes is not a general XML syntax or encoding-aware security validator.

Namespace identity belongs in the schema

The example intentionally supports a simple unnamespaced vocabulary. A namespace-aware application should enable and test namespace handling, then compare expanded names according to its grammar. Prefix text is not a durable namespace identity: two different prefixes can refer to one namespace, and one familiar prefix can be rebound to a different URI.

Decide whether attributes are allowed, required, or prohibited on each element. Do not silently accept an unknown attribute that changes a downstream interpretation, and do not assume namespace declarations are ordinary application fields. Test alternate prefixes, default namespaces, unexpected namespace URIs, and unqualified attributes separately.

Mixed content needs a different state model from the text-only item delegate. If child markup contributes text, track nesting and define how boundaries join; if markup is forbidden, reject it. Resetting one global buffer on every start event loses parent text. CDATA likewise needs an explicit handler and encoding policy if the product accepts it.

Publish only after complete success

parse() returns false on an error or an abort. Treat a delegate-triggered limit rejection as a failure even if some earlier items were collected. Keep tentative values private and publish the completed collection only after successful parsing and application validation.

On failure, record parserError and useful location information without dumping the whole input into logs. Preserve a distinction between malformed XML, unsupported grammar, exceeded limits, and application cancellation. These lead to different user messages and operational decisions.

If an import updates persistent records, parsing success still precedes a separate transaction. An error halfway through a document should not leave half the records installed unless partial import is an explicitly documented feature with recovery semantics. Prepare validated data first, then commit through the storage layer’s normal atomicity mechanism.

Verify chunking and rejection paths

Test the delegate directly with several text chunks for one item; parser buffering can otherwise make a fixture pass without exercising split accumulation. Add malformed closing tags, nested items, unknown attributes, empty content, non-ASCII content, the record limit, and a text chunk crossing the byte budget.

Use a controlled external-entity fixture and local observation to verify that no unexpected resolver or I/O path runs. Do not contact arbitrary hosts or read sensitive local files to test denial. Verify that malformed trailing XML prevents publication even when earlier items were valid.

The Swift fixture was executed on macOS with Foundation, together with direct split-character callbacks and four rejection fixtures covering attributes, nesting, excessive text, and malformed trailing XML. These checks establish the illustrated grammar and settings, not blanket immunity against all XML attacks. Production parsing is reliable when accepted vocabulary, resource limits, entity policy, and final publication are independently tested boundaries.

Related:

Sources:

Comments