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 每次都會回傳完整欄位嗎?
不會。同一欄位的文字可能分成多次回呼。應累積到元素結束才驗證,而且必須等整份文件與業務檢查通過,才提交結果。