技术文章

Delphi 中用 PDFium Component 给 PDF 解析器设定资源预算

PDFium Component 暴露了 TPdfParserResourceBudget——一个单条记录,为库里每个 PDF 解析器约束递归深度、token 数、对象数、解码字节、膨胀比、分配量,以及一个墙钟截止时间。ConfigurePdfParserResourceBudget 以原子方式为进程设置它,而每次解析都复制一份快照,让阈值在操作中途无法漂移

六个解析器,六套私有的上限

逼出这个功能的处境很平常,八成你也熟。稀疏 xref 遍历有自己的对象上限。流检查器有自己的解码字节上限。XMP 有一套,XFDF 和 FDF 另有一套,内容规范化还有第三套,preflight 默认值是第四套。这些数字单独看每个都说得过去,但外部一个都够不着——于是想表达「这个摄入路径处理匿名上传,这里任何东西都不许分配超过 64 MiB、或者跑超过四秒」的调用方,找不到地方说这句话。第二个问题更糟,因为它是无声的:凡是限制真的住在一个可变全局里,一次在一套策略下开始的解析,完全可能在另一套策略下结束。一个线程调 setter 时,另一个线程正在对象流中途,于是那趟遍历的前半段受旧上限约束,后半段受新上限约束。什么都不崩、什么都不记日志,事后你甚至说不清这次操作到底在什么限制下跑的——而这恰恰是你需要限制的那个性质

TPdfParserResourceBudget 到底约束什么?

七个维度,选它们是因为每一个都能拦下其他维度放过去的一类输入。MaxRecursionDepth 默认 1024,防止嵌套字典和数组把 ISO 32000-1 §7.3 的对象语法变成栈溢出。MaxTokens 默认 10,000,000,与文件大小无关地约束词法分析的工作量。MaxObjects 默认 4,000,000,覆盖交叉引用子节计数、对象流 /N 值和图遍历。MaxDecodedBytes 默认 256 MiB,MaxAllocationBytes 默认 512 MiB,所以文件能在分配之前被拒绝,而不是分配之后。MaxExpansionRatio 默认 1000.0,是那个最容易被低估的:ISO 32000-1 §7.4 的流过滤器是通用压缩,几 KB 的 Flate 数据合法地膨胀成几百 MB 完全可能,膨胀比上限就是对抗解压炸弹的标准防御。它和绝对字节上限正交,因为一份想从 200 KiB 变成 200 MiB 的流,在 256 MiB 上限之下却照样荒谬。DeadlineMilliseconds 默认 0,也就是没有截止时间,这个维度严格选择加入。整套默认值是有意放宽的,尺寸按「别让普通业务文档碰到任何限制」来定,不是加固配置;如果你的输入来自公开上传表单,你想要的应该更接近下面这些数字,同时配合加固 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 进入临界区,交回记录的按值副本,此后解析再也不会碰可变全局状态。结果是:另一个线程调 ConfigurePdfParserResourceBudget 只影响下一次操作,绝不影响在飞的那一次。并发的解析在不同策略下互不干扰地跑,因资源限制失败的操作是败在单一套一致的阈值上,热路径上也没有锁竞争,因为锁每趟解析只取一次,而不是每个 token 取一次

PDFium Component 中快照策略的示意图:进程级 TPdfParserResourceBudget 记录待在一道临界区后面,每次解析调用一次 GetPdfParserResourceBudget 拿按值副本,每个检查点都拿本地快照比较,另一个线程并发调用 ConfigurePdfParserResourceBudget 只影响下一次操作,绝不影响在飞的那一次
把策略读一次进本地记录,意味着来自另一个线程的配置变更只影响下一次操作,热路径上也没有锁竞争
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 调一次在正确性上没问题,但没有意义:你每一轮迭代都在付临界区开销,还把快照本来要防止的操作中途漂移重新请了回来

为什么字节上限挡不住病态文件?

因为在 PDF 里,大小和开销只是松散相关。一份 400 KiB 的文件,带着 ISO 32000-1 §7.5.8 交叉引用流、/Index 和 /W 数组描述一个看似合理实则敌意的条目数,或者一条反复回访同一偏移量的对象流链,或者一串每个操作符都比表面看着贵的 content stream,就能舒舒服服待在你设的每个字节和对象上限之下,还占着工作线程好几分钟。字节数约束内存,约束不了时间。这就是 PdfParserStartTick 和 PdfParserDeadlineReached 的用途。PdfParserStartTick 在操作开始时抓一个 tick 值,PdfParserDeadlineReached 拿已用时间和 DeadlineMilliseconds 比较,截止时间为 0 时一律返回 False。减法走无符号算术,所以 tick 计数器回绕不会产生假超时。交叉引用解析、对象流解码、内容 token 规范化和 FDF、XFDF 读取器都在循环头检查它,于是超预算的解析在检查点停下来,而不是从外面被杀掉

两条诚实的告诫。它是墙钟时间,不是 CPU 时间,所以一台在换页的机器、或者负载下的构建服务器,可能触发一台安静的机器不会触发的截止时间——这支持把值设在观察到的坏情况之上很远,而不是贴近中位数。而且它默认关闭,因为在月末批处理时,截止时间在合法的大文档上触发,比它防止的那个失败更糟。按摄入路径选择加入,而不是全局开,再配合验证交叉引用流里的结构性检查一起用

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

每个解析器保留自己的格式专属常量,并且两者都应用。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 个字段已经远超真实注解导出会产生的数量——提高全局上限也不该悄悄放宽某个格式专属的 sanity 检查。内容规范化从另一个方向做同样的事: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。在想要已知状态的测试清理里用它;生产代码里用保存-恢复,因为宿主应用启动时配置了什么,就是你必须回到的状态

预算在哪里不再帮忙

它是单一的进程级策略,这是它主要的局限。你不能给一个工作线程严格配置、给另一个宽松配置,因为一道临界区后面只有一条记录;如果你真的需要按租户限额,行得通的形状是把每种策略类别各自序列化到自己的进程里,或者用保存-恢复把工作括起来、同时接受这个括号只在没有其他东西并发解析时才安全。它约束的也只是解析,不是渲染:光栅化内存、字体缓存增长和图片解码都不在这七个维度之内,需要各自的控制。而且预算只能告诉你一次操作太贵,永远不告诉你为什么。被触发的上限是症状,它背后的畸形 /Index 数组或失控的过滤器链还是得手工诊断——这正是对象流与 predictor 解码里描述的解码路径值得在动手调数字之前先搞懂的原因。把预算当成「别让坏文档把进程一起拖下水」的东西,同时把日志写到足够好,好能查清文档当时在干什么

完整的解析器 API 面、预算记录,以及 Delphi、C++Builder 和 Lazarus 的源码构建,都在产品页上:PDFium Component