クラウド Mac で検証する iOS XML 解析:外部実体・名前空間・資源上限
ローカルでは読み込める XML でも、無人実行のパイプラインでは処理が止まることがあります。外部実体の宣言、深すぎる要素の入れ子、異なる名前空間に属する同名フィールドなどが原因になり得ます。クラウド Mac で行うべきなのは、こうした入力を「できるだけ修復する」ことではありません。許容範囲を外れた文書を確実に拒否し、再現可能な失敗サンプルを残すことです。
パーサーを呼ぶ前に入力契約を定める
iOS アプリの設定インポート機能を例に、まず受け入れ条件を決めます。文字コードは UTF-8 のみ、DTD は禁止、入力は 1 件あたり最大 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 階層の入れ子、テキスト量の超過、入力バイト数の超過という 6 種類のファイルを用意します。次のコマンドで、境界を超えるサンプルを 2 つ生成します。実行環境には 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 を返すはずです。比較演算子の誤りを検出するため、上限ちょうどのサンプルと、1 バイトだけ超えるサンプルも対にして用意します。外部実体を含むサンプルは、解析を始める前に拒否されなければなりません。パーサーの外部リソース設定を個別に検証する場合は、管理下にあるテスト対象を使い、実際のアクセスが発生していないことをアサーションで確認します。結果が空だったというだけで、安全だと判断してはいけません。
名前空間とテキストコールバックは構造に沿って扱う
名前空間を有効にした場合、業務フィールドの識別には「名前空間 URI + ローカル名」を使います。接頭辞だけ、あるいは elementName だけで比較してはいけません。接頭辞は変更可能であり、異なる URI に属する name は別の概念を表すことがあります。接頭辞を変更しても受け入れることと、URI を変更したら拒否することの両方をテストしてください。
foundCharacters がフィールド全体を一度に渡す保証はありません。実際のデコーダーでは要素スタックを管理し、テキストをコールバックごとに追加し、要素の終了時に長さ、形式、フィールドの重複を検査します。CDATA は別のコールバックで渡されるため、集計から漏らしてはいけません。また、すべてのフィールドで一律に前後の空白を削除するのも避けます。表示名と、空白を保持する本文とでは、入力契約が異なる可能性があります。
途中までの結果を確定しない
すべてのレコードを、まずジョブ専用の一時モデルに格納します。構文、上限、ルート要素、業務ルールの検査がすべて通った場合に限り、一度だけコミットします。文書の末尾に意図的に不正なタグを追加し、それまでに読み取ったレコードもデータベースへ保存されないことを確認してください。
一度の手動テストで終わらせず、ビルドに検証を組み込む
OSXBuild のクラウド Mac では、まずコマンドラインのサンプルを実行し、続いて同じファイルを iOS のテストターゲットに追加して、アプリが実際に使うデコーダーを呼び出します。両者の入力契約は共通ですが、デスクトップ用スクリプトの成功をもって、シミュレータ上の実装検証に代えることはできません。
回帰テストの記録には、サンプル名、期待する終了コード、実際の結果、ツールチェーンのバージョンを残せば十分です。インポート内容全体をログに出力する必要はありません。異常なジョブはパイプラインのタイムアウトで制限します。コールバック側の上限で制約できるのは、すでに渡されたデータだけであり、パーサー内部のメモリ使用量に絶対的な上限を設けるものではありません。
最後に、正常なファイルをインポートできること、上限を外れたファイルが確実に失敗すること、名前空間をまたいでフィールドを取り違えないこと、失敗時に中途半端なデータが残らないことを一つずつ確認します。こうしておけば、次に解析ロジックを変更したときも、検証の根拠は「今回は止まらなかった」ではなく、再実行できる入力と明確なアサーションになります。
よくある質問
外部実体の解決を無効にすれば十分ですか?
十分ではありません。外部実体の設定は内部 DTD を禁止しません。この記事では DTD を含まない UTF-8 XML に入力を限定し、解析前に保守的な検査を行います。
foundCharacters はフィールド全体を一度に返しますか?
返すとは限りません。同じ要素の文字列が複数回に分かれて届くため、終了要素まで蓄積します。文書全体と業務上の検査が成功してから結果を保存します。
ビルド専用のクラウド Mac を
物理マシン1台をまるごと専有でき、仮想マシンではありません。ビルド量に合わせてマシンと利用期間を選べば、アーカイブ、署名、キャッシュを専用ノードに保管できます。
クラウド Mac のプランを選ぶ