技術文章

PDFium Component 中 PDF 剖析器的資源預算(Delphi)

PDFium Component 提供 TPdfParserResourceBudget,一個單一 record,同時約束程式庫裡每個 PDF 剖析器的遞迴深度、token 數、物件數、解碼位元組數、擴充比、配置量與牆鐘期限。ConfigurePdfParserResourceBudget 以不可分割的方式為整個程序設定它,每次剖析複製一份快照,因此門檻值不會在操作中途改變

六個剖析器,六個私有的上限

逼出這個設計的狀況很平凡,而且大概很眼熟。稀疏 xref 遍歷有自己的物件上限,串流檢查器有自己的解碼位元組上限,XMP 有一套,XFDF 與 FDF 有另一套,內容正規化有第三套,preflight 預設值有第四套。這些數字單獨看每個都合理,但從外部一個都碰不到,所以一個想說「這條輸入路徑處理匿名上傳,這裡任何東西都不能配置超過 64 MiB 或跑超過四秒」的呼叫端,根本沒有地方可以說。第二個問題更糟,因為它無聲:當某個上限活在可變動的全域變數裡,一份在一種政策下開始的剖析,可能在另一種政策下結束。一個執行緒呼叫 setter,同時另一個執行緒剖析物件串流剖析到一半,那趟遍歷的前半段被舊上限約束、後半段被新上限約束。沒有崩潰、沒有日誌,事後您也說不出這操作實際是在什麼上限下跑的——而那正是您需要上限的原因

TPdfParserResourceBudget 實際約束了什麼?

七個維度,每個都攔得住其他維度放行的某一類輸入。MaxRecursionDepth 預設 1024,阻止巢狀字典與陣列把 ISO 32000-1 §7.3 的物件語法變成堆疊溢位。MaxTokens 預設 10,000,000,與檔案大小無關地約束 lexing 工作量。MaxObjects 預設 4,000,000,涵蓋 cross-reference 子區段數量、物件串流的 /N 值與圖形遍歷。MaxDecodedBytes 預設 256 MiB、MaxAllocationBytes 預設 512 MiB,因此檔案可以在配置之前被拒絕,而不是配置之後。MaxExpansionRatio 預設 1000.0,是最被低估的一個:ISO 32000-1 §7.4 的串流 filter 是通用壓縮,幾 KB 的 Flate 資料合法地展開成數百 MB 很正常,比值上限是對解壓縮炸彈的標準防禦。它與絕對位元組上限正交,因為一份想從 200 KiB 變成 200 MiB 的串流,低於 256 MiB 天花板卻仍然荒謬。DeadlineMilliseconds 預設 0,代表沒有期限,所以這個維度是嚴格選擇加入。整組預設值是刻意寬鬆的,尺寸只求讓一般商業文件不會誤觸任何東西,而不是當成加固設定檔;如果您的輸入來自公開上傳表單,您會想要接近下面這些數字,再加上用 PDFium Component 在 Delphi 中安全預覽 PDF裡描述的規則

PDFium Component 中 TPdfParserResourceBudget 所約束的七個維度示意圖:MaxRecursionDepth 1024、MaxTokens 10000000、MaxObjects 4000000、MaxDecodedBytes 256 MiB、MaxAllocationBytes 512 MiB、作為解壓縮炸彈防禦的 MaxExpansionRatio 1000.0,以及選擇加入的 DeadlineMilliseconds 牆鐘期限,全部由 ConfigurePdfParserResourceBudget 以不可分割方式為程序設定
七個維度各自攔住其他維度放行的某一類輸入,擴充比就站在幾 KB 的 Flate 資料與數百 MB 之間
uses
  FPdfPdfCommon;

procedure ApplyUntrustedIntakeBudget;
var
  Budget: TPdfParserResourceBudget;
begin
  Budget := TPdfParserResourceBudget.Default;
  // 保留預設值:MaxRecursionDepth 1024、MaxTokens 10000000、
  // MaxObjects 4000000
  Budget.MaxDecodedBytes      := Int64(32) * 1024 * 1024;  // 預設 256 MiB
  Budget.MaxAllocationBytes   := Int64(64) * 1024 * 1024;  // 預設 512 MiB
  Budget.MaxExpansionRatio    := 50.0;                     // 預設 1000.0
  Budget.DeadlineMilliseconds := 4000;                     // 預設 0 = 關閉
  ConfigurePdfParserResourceBudget(Budget);
end;

為什麼取快照而不是直接讀全域變數?

因為一個執行到一半會變動的上限,就不是上限。這是整個功能的核心設計點,也是每個入口點的一行紀律:操作開始時把政策讀進一個區域變數 TPdfParserResourceBudget,之後每個檢查點都跟這份區域副本比較。GetPdfParserResourceBudget 取得 critical section,交回 record 的值副本,之後剖析再也不碰可變動的全域狀態。結果是:另一個執行緒呼叫 ConfigurePdfParserResourceBudget 只影響下一次操作,永遠不影響進行中的那一個。並行的剖析在不同的政策下執行互不干擾,因資源上限而失敗的操作是敗在同一組一致的門檻值上,而且熱路徑上沒有鎖競爭——鎖每次剖析取一次,而不是每個 token 取一次

PDFium Component 的快照政策示意圖:全程序唯一的 TPdfParserResourceBudget record 待在一道 critical section 後面,每次剖析呼叫 GetPdfParserResourceBudget 一次取得值副本,每個檢查點與那份區域快照比較,另一個執行緒併發呼叫 ConfigurePdfParserResourceBudget 只影響下一次操作,永遠不影響進行中的那一個
把政策讀進區域 record 一次,另一個執行緒的設定變更就只影響下一次操作,熱路徑上也不會有鎖競爭
type
  TCustomScanState = record
    Budget: TPdfParserResourceBudget;
    StartTick: Cardinal;
    TokenCount: Int64;
    Depth: Integer;
  end;

procedure BeginScan(var S: TCustomScanState);
begin
  S.Budget := GetPdfParserResourceBudget;  // 開頭取一次快照
  S.StartTick := PdfParserStartTick;
  S.TokenCount := 0;
  S.Depth := 0;
end;

function ScanCheckpoint(var S: TCustomScanState): Boolean;
begin
  Result := False;
  if PdfParserDeadlineReached(S.StartTick, S.Budget) then
    Exit;
  Inc(S.TokenCount);
  if S.TokenCount > S.Budget.MaxTokens then
    Exit;
  if S.Depth > S.Budget.MaxRecursionDepth then
    Exit;
  Result := True;
end;

如果您在程式庫之上寫自己的剖析器,請照抄那個形狀,而不是在 token 迴圈裡呼叫 GetPdfParserResourceBudget。每個 token 呼叫一次是正確但無意義的:您每一輪都付一道 critical section,還把快照本來要防止的操作中途漂移重新請回來

為什麼位元組上限擋不住病態檔案?

因為在 PDF 裡,大小與成本只是鬆散相關。一份 400 KiB 的檔案,帶一條 ISO 32000-1 §7.5.8 cross-reference 串流,它的 /Index 與 /W 陣列描述一個看似合理其實敵意的項目數;或一條一再回到相同偏移量的物件串流鏈;或一條每個 operator 都比表面貴的內容串流——這些都可以舒服地待在您設的每個位元組與物件上限之下,仍佔著一個 worker thread 好幾分鐘。位元組數約束記憶體,不約束時間。這就是 PdfParserStartTick 與 PdfParserDeadlineReached 的用途。PdfParserStartTick 在操作開始時捕捉一個 tick 值,PdfParserDeadlineReached 把經過時間與 DeadlineMilliseconds 比較,期限為 0 時一律回傳 False。相減在無號算術中進行,因此 tick 計數器翻轉不會產生虛假的逾時。Cross-reference 剖析、物件串流解碼、內容 token 正規化、FDF 與 XFDF 閱讀器都在迴圈頭檢查它,所以超預算的剖析在檢查點停下來,而不是從外部被殺掉

兩個誠實的但書。這是牆鐘時間,不是 CPU 時間,所以一台在 swap 的機器或負載下的建置伺服器,可能觸發安靜機器能輕鬆通過的期限——這說明了為什麼要設在您觀察到的最壞情況之上,而不是接近中位數。而且它預設關閉,因為一份合法的超大文件在月底批次處理時觸發期限,會是比它要防止的失敗更糟的失敗。依輸入路徑選擇加入,不要全域開啟,並搭配驗證物件與 XRef 串流裡的結構檢查

預算是上限,不是格式限制的替代品

每個剖析器保留自己格式專屬的常數,而且兩者都套用。XFDF 是最清楚的例子:欄位閱讀器在快照值之外,還強制 XfdfMaxTopLevelFields 與一個 100000 的 MaxFieldNodes 常數,任何一個都可以中止剖析

const
  XfdfMaxTopLevelFields = 100000;   // 格式專屬,永遠套用
...
  // 兩個上限都是有效的;誰低誰勝出
  if (FieldCount >= XfdfMaxTopLevelFields) or
     (FieldCount >= Budget.MaxObjects) then
    Exit(False);

這是刻意的,而且值得在您去尋找一個不存在的旋鈕之前先內化。全域 4,000,000 個物件的預算,不代表一份 XFDF 檔案可以有 4,000,000 個欄位,因為 100000 個欄位已經遠超出任何真實註解匯出的產量,而調高全域上限不該默默地放寬格式專屬的健全性檢查。內容正規化從另一個方向用同樣的方式運作:TPdfContentNormalizeOptions.Default 從自己每頁的上限出發,再把其中每一個往下夾到快照值,所以 MaxTokensPerPage 只會被預算降低,永遠不會被調高。全域政策只會收緊,不會放鬆

PDFium Component 中剖析器預算如何作為上限的示意圖:XFDF 欄位閱讀器在 MaxObjects 的快照值之外強制 XfdfMaxTopLevelFields 為 100000,誰低就由誰中止剖析,而 TPdfContentNormalizeOptions 把 MaxTokensPerPage 往下夾到預算值,因為全域政策只會收緊限制、不會放鬆
全域 4,000,000 個物件的預算不會讓 XFDF 檔案擁有 4,000,000 個欄位,每個剖析器保留自己的格式專屬常數,兩個上限同時套用、低的勝出

壞設定在設定的當下就失敗

ConfigurePdfParserResourceBudget 在指派之前先驗證。六個數值維度裡任何一個是非正值都會立刻拋出,MaxDecodedBytes 高於 High(Integer) 會被當作超過平台上限而拒絕,而不是在某個解碼器深處被默默截斷。這件事比聽起來更重要:從少了某個鍵的設定檔建出來的預算會拿到一堆零,而啟動時被接受的零物件上限,會在三小時後的第一份文件上變成一個莫名其妙的剖析失敗,屆時沒人會懷疑設定檔。同樣的謹慎也適用於任何暫時收緊預算的程式碼,包括測試。政策是全域的,所以 fixture 結束後還留著 4 秒期限,會毒害之後的每一個測試;在變更前後儲存並還原

var
  Saved: TPdfParserResourceBudget;
begin
  Saved := GetPdfParserResourceBudget;
  try
    ApplyUntrustedIntakeBudget;
    ProcessInboundDocument(FileName);
  finally
    ConfigurePdfParserResourceBudget(Saved);
  end;
end;

ResetPdfParserResourceBudget 是更粗暴的版本,直接還原 TPdfParserResourceBudget.Default。在想要已知狀態的測試 teardown 裡用它,而在生產程式碼用儲存與還原——主程式啟動時設定了什麼,就是您必須回到的狀態

預算在哪裡幫不上忙

它是單一的全程序政策,而這正是它主要的限制。您不能給一個 worker thread 嚴格的設定檔、給另一個寬鬆的設定檔,因為一道 critical section 後面只有一個 record;如果真的需要依租戶(tenant)設上限,可行的形狀是把每個政策類別序列化到各自的 process 上,或者用儲存與還原包住工作,並接受只有在沒有其他人同時剖析時這個包圍才是安全的。它也只約束剖析、不約束渲染:點陣化記憶體、字型快取成長與影像解碼都在這七個維度之外,需要自己的控制項。而且預算只會告訴您操作太貴,永遠不會告訴您為什麼。觸發的上限是症狀,背後那個畸形 /Index 陣列或失控的 filter 鏈還是要人工診斷,這就是為什麼在您開始動數字之前,值得先理解物件串流與 predictor 解碼裡描述的途徑。把預算當成「讓壞文件不會把整個程序一起拖下水」的東西,同時把您的日誌寫到足以查出那份文件到底在做什麼

完整的剖析器 API 面、預算 record 與 Delphi、C++Builder 和 Lazarus 的原始碼建置都在產品頁面上:PDFium Component