XML 导入在本地读得通,放进无人值守流水线却可能卡住:文件带了外部实体声明、元素嵌套过深,或同名字段来自不同命名空间。云端 Mac 的任务不是替这些输入“尽量修好”,而是稳定拒绝越界文档,并留下可重放的失败样本。
先写输入契约,再调用解析器
以 iOS 的配置导入功能为例,先约定:只收 UTF-8,不允许 DTD;单份输入最多 256 KiB,元素深度最多 16 层,所有文本回调累计最多 32 KiB。这些是本文的示例配额,不是 XML 标准或系统默认值,应按业务调整。
把格式错误、配额超限和业务字段错误分开记录。不能因为解析器返回成功,就接受未知根元素、重复单值字段或缺少必填项的文档。
关闭外部实体解析,不等于禁止 DTD,也不等于获得完整的资源隔离。格式约束、解析器设置与进程级超时需要分别验收。
在云端 Mac 跑通最小安全检查
将下面代码保存为 xml-guard.swift。它只检查语法与资源边界,不生成业务模型。脚本采用保守策略:连注释中出现的 DTD 声明标记也拒绝,避免把简单文本检查误称为完整的 XML 词法分析。
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)
}
在已选择工具链的终端编译,再执行一个合法样本:
xcrun swiftc xml-guard.swift -o xml-guard
printf '%s\n' '<settings><name>demo</name></settings>' > valid.xml
./xml-guard valid.xml
退出码约定为:0 通过,1 输入被拒绝,2 调用或文件读取失败。流水线据此分类,不要解析系统语言相关的错误句子。入口应接收任务自己暂存的普通文件,不能把任意设备路径当成输入。
用固定样本覆盖拒绝路径
至少保留六类文件:合法文档、标签未闭合、带 DTD、17 层嵌套、文本超额、输入字节超额。下面生成两个边界样本;运行环境需提供 Python 3。
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
两次都应返回 1。另做一对恰好等于配额、只多一个字节的样本,防止比较符号写错。外部实体样本必须在解析前被拒绝;若要单独验证解析器的外部资源设置,应使用受控测试目标,并断言没有实际访问,不能仅凭结果为空推断安全。
命名空间与文本回调要按结构处理
启用命名空间后,业务字段身份应使用“命名空间 URI + 本地名称”,不能只比较前缀,也不能只比较 elementName。前缀可以改变;不同 URI 下的 name 则可能代表不同概念。测试应同时覆盖换前缀仍接受、换 URI 必须拒绝。
foundCharacters 不保证一次给出完整字段。实际解码器应维护元素栈,逐次追加文本,在结束元素时做长度、格式与重复字段检查。CDATA 走独立回调,不能漏计。也不要对所有字段统一去除首尾空白:显示名称与保留空白的正文可能有不同契约。
不提交半份结果
所有记录先放进任务私有的临时模型。只有语法、配额、根元素与业务检查全部通过,才执行一次提交。给文档末尾故意补一个错误标签,验证前面已读出的记录仍不会进入数据库。
把验收接入构建,而不是依赖一次手测
在 OSXBuild 云端 Mac 上,先运行命令行样本,再把相同文件放进 iOS 测试目标,调用应用实际使用的解码器。两者共享契约,但桌面脚本通过不能代替模拟器中的实现验收。
回归记录保存样本名称、预期退出码、实际结果与工具链版本即可,不回显完整导入内容。用流水线超时限制异常任务;回调配额只能约束已交付的数据,不是解析器内部内存的绝对上限。
最后逐项确认:合法文件能导入,越界文件稳定失败,命名空间不串字段,失败不留下半成品。这样下次修改解析逻辑时,验收依据仍是可重放的输入与明确断言,而不是“这次没有卡住”。
常见问题
关闭外部实体解析后,还需要拒绝 DTD 吗?
需要分别处理。关闭外部实体解析只控制外部资源解析,不等于拒绝内部 DTD。本文将输入格式限定为无 DTD 的 UTF-8 XML,并在交给解析器前执行保守拒绝。
foundCharacters 每次都会返回完整字段吗?
不会。解析器可以分多次回调同一字段的文本。应按当前元素累积内容,在结束元素时校验,并且只在整份文档解析与业务检查均通过后提交结果。