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裡描述的規則
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 取一次
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 只會被預算降低,永遠不會被調高。全域政策只會收緊,不會放鬆
壞設定在設定的當下就失敗
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