Test iOS XML parser safety on a cloud Mac: entities, namespaces and limits

Test iOS XML parser safety on a cloud Mac: entities, namespaces and limits

XML imports that work locally can stall in an unattended pipeline: a file may declare external entities, nest elements too deeply, or contain identically named fields from different namespaces. The cloud Mac’s job is not to “fix up” these inputs as best it can. It is to reject documents that violate the contract consistently and retain failing fixtures that can be replayed.

Define the input contract before calling the parser

For an iOS configuration import feature, start with an explicit contract: UTF-8 only, no DTDs, a maximum input size of 256 KiB, a maximum element depth of 16, and a cumulative limit of 32 KiB across all text callbacks. These are example quotas for this article, not XML standard limits or system defaults. Adjust them to your application’s needs.

Log malformed XML, quota violations, and invalid application fields separately. A successful parse is not a reason to accept a document with an unknown root element, duplicate single-value fields, or missing required fields.

Disabling external entity resolution does not prohibit DTDs or provide complete resource isolation. Format constraints, parser settings, and process-level timeouts must each be verified separately.

Run a minimal safety check on a cloud Mac

Save the following code as xml-guard.swift. It checks only syntax and resource boundaries; it does not build an application model. The script takes a conservative approach: it rejects DTD declaration markers even inside comments, rather than presenting a simple text check as a full XML lexer.

import Foundation

final class GuardDelegate: NSObject, XMLParserDelegate {
    var depth = 0
    var textBytes = 0
    var exceeded = false

    func stop(_ parser: XMLParser) {
        exceeded = true
        parser.abortParsing()
    }

    func parser(
        _ parser: XMLParser,
        didStartElement elementName: String,
        namespaceURI: String?,
        qualifiedName qName: String?,
        attributes attributeDict: [String: String] = [:]
    ) {
        depth += 1
        if depth > 16 { stop(parser) }
    }

    func parser(
        _ parser: XMLParser,
        didEndElement elementName: String,
        namespaceURI: String?,
        qualifiedName qName: String?
    ) {
        depth -= 1
    }

    func parser(_ parser: XMLParser, foundCharacters string: String) {
        textBytes += string.utf8.count
        if textBytes > 32 * 1024 { stop(parser) }
    }

    func parser(_ parser: XMLParser, foundCDATA data: Data) {
        textBytes += data.count
        if textBytes > 32 * 1024 { stop(parser) }
    }

    func parser(
        _ parser: XMLParser,
        resolveExternalEntityName name: String,
        systemID: String?
    ) -> Data? {
        nil
    }
}

func validate(_ path: String) throws -> Bool {
    let handle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path))
    defer { try? handle.close() }

    var data = Data()
    while true {
        guard let chunk = try handle.read(upToCount: 8192),
              !chunk.isEmpty else { break }
        data.append(chunk)
        if data.count > 256 * 1024 { return false }
    }

    guard let source = String(data: data, encoding: .utf8),
          !source.contains("\0"),
          !source.contains("<!DOCTYPE") else {
        return false
    }

    let delegate = GuardDelegate()
    let parser = XMLParser(data: data)
    parser.delegate = delegate
    parser.shouldProcessNamespaces = true
    parser.shouldResolveExternalEntities = false
    parser.externalEntityResolvingPolicy = .never

    let parsed = parser.parse()
    return parsed && !delegate.exceeded && delegate.depth == 0
}

guard CommandLine.arguments.count == 2 else { exit(2) }
do {
    exit(try validate(CommandLine.arguments[1]) ? 0 : 1)
} catch {
    exit(2)
}

Compile it in a terminal with the toolchain selected, then run it against a valid sample:

xcrun swiftc xml-guard.swift -o xml-guard
printf '%s\n' '<settings><name>demo</name></settings>' > valid.xml
./xml-guard valid.xml

The exit codes are 0 for a pass, 1 for rejected input, and 2 for an invocation or file-read failure. Use these codes to classify pipeline results instead of parsing error messages that vary with the system language. The entry point should accept regular files staged by the job itself, not arbitrary device paths.

Cover rejection paths with fixed fixtures

Keep at least six categories of fixtures: a valid document, an unclosed tag, a DTD, 17 levels of nesting, excessive text, and excessive input bytes. The following commands generate two boundary fixtures; Python 3 must be available in the environment.

python3 - <<'PY'
from pathlib import Path

Path("deep.xml").write_text(
    "<n>" * 17 + "</n>" * 17, encoding="utf-8"
)
Path("text-limit.xml").write_text(
    "<n>" + "x" * (32 * 1024 + 1) + "</n>", encoding="utf-8"
)
PY
./xml-guard deep.xml
./xml-guard text-limit.xml

Both runs should return 1. Also create a pair of fixtures that land exactly on a quota and exceed it by just one byte, to catch incorrect comparison operators. External entity fixtures must be rejected before parsing. To test the parser’s external resource settings independently, use a controlled test target and assert that no access actually occurs. An empty result alone is not evidence of safety.

Handle namespaces and text callbacks structurally

With namespace processing enabled, identify application fields by “namespace URI + local name.” Do not compare prefixes alone, or rely solely on elementName. Prefixes can change, while name under different URIs may represent different concepts. Tests should verify both that changing the prefix is accepted and that changing the URI is rejected.

foundCharacters is not guaranteed to deliver a complete field in one call. The actual decoder should maintain an element stack, append text as callbacks arrive, and check length, format, and duplicate fields when an element ends. CDATA uses a separate callback and must count toward the limit too. Do not trim leading and trailing whitespace from every field indiscriminately: display names and body text that preserves whitespace may have different contracts.

Never commit partial results

Stage all records in a temporary model private to the job. Commit once, and only after syntax, quotas, the root element, and application-level checks have all passed. Deliberately append an invalid tag to the end of a document and verify that records already read still do not reach the database.

Make acceptance checks part of the build, not a one-off manual test

On an OSXBuild cloud Mac, run the command-line fixtures first, then add the same files to an iOS test target and call the decoder the app actually uses. Both must follow the same contract, but a passing desktop script does not replace acceptance testing of the implementation in the simulator.

Regression records need only include the fixture name, expected exit code, actual result, and toolchain version. Do not echo the full imported content. Use a pipeline timeout to bound abnormal jobs; callback quotas constrain only the data already delivered, not the parser’s total internal memory use.

Finally, verify each requirement: valid files import successfully, out-of-bounds files fail consistently, namespaces do not cause fields to be confused, and failures leave no partial results. The next time the parsing logic changes, acceptance will still rest on replayable inputs and explicit assertions—not on “it didn’t hang this time.”

Frequently asked questions

Is disabling external entity resolution enough?

No. That setting does not prohibit internal DTDs. This workflow accepts only UTF-8 XML without DTDs and applies a conservative check before invoking the parser.

Does foundCharacters return a complete field each time?

No. A field can arrive in several callbacks. Accumulate text until the element closes, and commit results only after the entire document and business validation succeed.

Give your build pipeline a dedicated cloud Mac

Get an entire physical machine, not a virtual machine. Choose a model and rental term to match your build volume, and keep archives, signing, and caches on your own node.

Choose a cloud Mac plan

Your next archive, built on a dedicated cloud Mac

Choose a machine for your build volume and a location for your team. Review the plan, then start handing over your toolchain.