把 CCITT、TIFF、PNG、Flate 和流缓冲代码搬到 Free Pascal 上编译,是找出「只在 Delphi 上碰巧能跑的 Pascal」的可靠办法。下面五种模式没有一种涉及编译器 bug。每一种都是因为某个实现细节才在 Delphi 上碰巧执行正确的代码:一个与调用方数组别名的隐藏 result 参数、一个没人读到越界分支之外的越界分支、唯一防线是范围检查开关的零长度缓冲区、只有一条代码路径会传 1 的 1 基偏移,以及一个内存流永远不会触发的 TStream.Read 契约。换一个编译器,或者给同样的代码喂一个畸形文件,这个巧合就不再成立
接下来是每个陷阱的具体形状、PDF Library for Delphi 的处理方式,以及背后的纪律:同一份源码必须在两个编译器上产生相同的文档语义,而想知道这一点只有两边都查。姊妹篇加固 Pascal PDF 解析器以抵御恶意文件讲整数宽度、递归深度和未初始化缓冲区。这篇讲的是另一类故障:代码一直是错的,只是编译器一直在悄悄兜底
为什么在 Delphi 上返回动态数组的函数不调用 SetLength 也能跑?
因为 Delphi 会把调用方自己的变量作为隐藏 result 参数传进来,所以一个从不给 Result 分配内存的函数,仍能写进调用方分配的数组。TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray 是二维 Group 3 和 Group 4 解码核心的参考行查找:给定当前位置 a0 和当前行程的颜色,它在上一扫描行的变化元素——ITU-T T.4 和 T.6 二维编码方案里的 b1 和 b2——中搜索,并以双槽数组返回。诱人的实现直接写 Result[0] 和 Result[1],从头到尾不对 Result 调用 SetLength
这理应在第一次写入时就出错,在 Free Pascal 上也确实如此。在 Delphi 上不会,只要调用点长这样:声明 b: TCCITTIntegerArray,在扫描行循环前执行一次 SetLength(b, 2),然后在循环内写 b := GetNextChangingElement(a0, IsWhite) 并读 b[0]、b[1]。Delphi 语言指南写明,结果为长字符串、动态数组或其他托管类型的函数会以一个额外的 var 参数接收该结果,实践中编译器传的是赋值目标的地址。于是函数内的 Result 就是 b 本身,已有两个元素,每次写入都落在调用方拥有的内存里。Free Pascal 则交给函数一个全新的 nil 数组,之后再赋给 b——这才是代码本来就该对照的契约读法
这种别名还带着解码器依赖的语义。Result[0] 只在扫描找到大于 a0 的元素时才被赋值,Result[1] 只在它后面还有元素时才被赋值,所以未命中时两个槽位保留上一轮迭代留在 b 里的东西。直观的修法——每次调用都分配两槽并清零——会破坏这种延续性,在 Delphi 上改变解码输出。PDF Library for Delphi 用守卫代替重置:在 Delphi 上它是死代码,解码路径逐字节保持不变;在 Free Pascal 上它把崩溃变成预期行为。这种不对称正是全部要点:守卫必须在代码已产出验证过输出的那个编译器上是无操作
Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
IsWhite: Boolean): TCCITTIntegerArray;
Begin
// Delphi 走到这里时,调用方的两元素数组已经别名成 Result,
// 所以这里在 Delphi 上是 no-op;FPC 拿到的是 nil
If (Length(Result) < 2) Then
SetLength(Result, 2);
...
// Result[0] / Result[1] 依然只在命中时写入,未命中时
// 保留上一轮迭代的值,与之前完全一致
End;
计数活得比数据长:TIFF 目录项
让数组失效时,必须在同一条语句里让它的计数一起失效,否则这个计数会被从未见过该数组的代码相信。TIFF 图像文件目录项(TIFF 6.0 §2,tag、type、count 和 value-or-offset 组成的 12 字节布局)携带直接取自文件的 32 位计数,PDF Library for Delphi 通过 PopDE: TTIFFEntry 读取每一项,该记录含 Tag、TagType、Length、Offset 以及解码出的 IntegerValues 和 DoubleValues 数组。天真的范围检查测试 Offset + TypeSize * Length 是否越过文件末尾,若是,就把两个数组置为零长度,却让 Result.Length 保持文件里的值
从这里开始有两件事出错。函数结尾有个回退逻辑,读作「如果 Length 为零,就给该条目一个零值元素」,好让调用方总能读元素零。而越界路径上 Length 从未清零,这个回退为它存在的唯一场景永远不会触发。而且调用方确实无条件地读元素零:Width、Height、BitsPerSample、PhotometricInterpretation、FillOrder、SamplesPerPixel、RowsPerStrip 等十几个地方都取 E.IntegerValues[0],条带表还执行 Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4),从一个空数组里复制 Length 乘四字节。带着有效计数的空数组,比不做检查的数组更危险,因为后者至少还握有它声称拥有的字节
第二个陷阱是顺序。若在范围测试之前、按文件里的计数执行两个 SetLength,一个恶意条目就能在通过任何有效性检查之前请求数吉字节的分配。在 Delphi 上,由此产生的异常通常被图像加载路径上层的处理器接住,文件只是加载失败,这正是问题容易被漏掉的原因;实际发生的是一次由文件自己挑选的内存耗尽事件。正确顺序是先测试后分配,并让计数与数据同进同出
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
> Length(Source);
If OutOfRange Then
Begin
Result.Length := 0; // 计数随数据一起清掉
SetLength(Result.IntegerValues, 0);
SetLength(Result.DoubleValues, 0);
End
Else
Begin
SetLength(Result.IntegerValues, Result.Length); // 到这里才分配
SetLength(Result.DoubleValues, Result.Length);
End;
// ……之后,回退逻辑终于轮到它为之存在的场景:
If (Result.Length = 0) Then
Begin
SetLength(Result.IntegerValues, 1);
Result.IntegerValues[0] := 0;
End;
这一条没有任何编译器特殊性,这正是它有资格进这份清单的原因。它在 Delphi 上潜伏的理由与在 Free Pascal 上相同:它需要一个指向文件末尾之外的目录项,而普通测试文件没有。移植不会暴露它;带着「Delphi 在这里替我做了什么我没自己做的工作」这个问题去读代码才会
PNG 的 IHDR 声称了一个格式没定义的颜色类型时会怎样?
PDF Library for Delphi 在行过滤器运行之前就拒绝该图像。ISO 15948 §11.2.2 定义了 IHDR 块,表 11.1 列出六种合法的颜色类型与位深组合:灰度 1、2、4、8 或 16 位,索引色 1、2、4 或 8 位,真彩、带 alpha 灰度和带 alpha 真彩为 8 或 16 位。陷阱在于一个只校验 IHDR 的压缩方法和过滤方法字段、让 FColorType 和位深原样通过的读取器:非法颜色类型随后产出零字节扫描行,反过滤循环拿到空缓冲区
行过滤代码通常用一个 Case FColorType Of 把每种颜色类型映射到分量数,再据此确定所有尺寸。六种之外的颜色类型落进 Else 分支,那里 SourceComponents 为 0,于是 ScanlineByteCount 为 0,于是 SetLength(PreviousScanline, 0) 之后紧跟 FillChar(PreviousScanline[0], ScanlineByteCount, 0)。对空动态数组取元素零,得到的是由 nil 算出的地址。范围检查关闭时,经该地址的零字节填充是无声的无操作,解码器在根本不存在的行上继续推进;范围检查开启时,它在第一张图上就是 ERangeError;紧随其后的 Move 调用则离访问违例一步之遥。得到哪一种结果取决于编译器和构建开关,而不取决于解码器的任何决定——这正是「解码器从未做过决定」的破绽
PDF Library for Delphi 在检查其他 IHDR 字段的地方应用规范里的这张表:COLOR_GRAYSCALE 接受 FSourceBitDepth in [1, 2, 4, 8, 16],COLOR_PALETTE 接受 [1, 2, 4, 8],COLOR_RGB、COLOR_GRAYSCALEALPHA 和 COLOR_RGBALPHA 接受 [8, 16];其余情况清掉 ValidImage,图像被拒绝且宽高保留供诊断。短于九字节的 pHYs 块也在同一处检查,DPI 读取器因此绝不会去索引被短块留空的字符串的 S[1] 到 S[8]
把 1 基偏移当成 0 基指针用
InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString 接受 1 基的 StartPos,因为它的输入是 AnsiString,Delphi 实现按 @Input[StartPos] 寻址 zlib 输入。Free Pascal 实现基于 paszlib 编写,这样两个 Windows 目标都能静态链接压缩库,它必须用指针运算寻址同一输入,而指针运算按 0 基。把 next_in 设为 PAnsiChar(Input) + StartPos、把 avail_in 设为 Length(Input) - StartPos,再传入 1——对这个函数而言「从头开始」的意思就是 1——FPC 构建就会从第二个字节开始解压,并在结束前一个字节停下
这个问题容易藏住,因为多数测试够到的调用方是 InflateStr,它传 0。零恰好是正确的 0 基偏移,所以两种构建在所有普通 InflateStr 调用和所有经过它的测试上表现一致。TPDFDocument.DecodeAllStreams——SaveQDFToFile 和 ConvertFileToQDF 用来把单个 FlateDecode 流展开成可读形式的例程——传 1。带着这个差一错误,被跳过的 zlib 头让解压失败,但 zlib 流仍对它检查过的字节报告非零的 Consumed,于是信任 Consumed 的调用方把空载荷当作成功解码,用空字符串替换掉每个内容流。得到的 QDF 页数正确、结构有效、页面无内容——一个在任何阅读器里都打开无错却什么都不显示的文件
// InflateStrFromPosition 的 FPC 分支。
// StartPos 与 Delphi 分支一样是 1 基;先钳制,再在边界处
// 恰好一次性转换成 0 基的指针偏移。
If (StartPos < 1) Then
StartPos := 1;
If (Length(Input) = 0) Or (StartPos > Length(Input)) Then
Exit;
...
strm.next_in := Pointer(PAnsiChar(Input) + StartPos - 1);
strm.avail_in := Length(Input) - StartPos + 1;
能抓住它的检查是最小的那种:压缩一个载荷,分别从位置 0 和位置 1 解压,确认两者返回相同载荷、且都报告 Consumed 等于整条流的长度。RFC 1950 流有两个字节的头和四个字节的 Adler-32 尾,任何一端的差一错误都不是轻微损坏,而是一条要么无法开始、要么无法收尾的流。教训关乎边界,不关乎 zlib:当函数参数按一种索引基定义、底层实现用另一种时,转换应该恰好落在一行里,而且测试必须用能区分两种基的那个值去调用它
为什么短读的 TStream.Read 不等于流结束?
因为 TStream.Read 允许以任何它乐意的理由返回比请求更少的字节,只有返回 0 才意味着没有更多数据了。本地磁盘上的 TMemoryStream 和 TFileStream 几乎总能填满请求,所以把「返回的比我要求的少」当作文件末尾的代码,在所有用它们的测试里都能通过。网络流、解压流,以及客户自己写的任何 TStream 派生类,都可以在你请求六万四千字节时返回两个字节,而后面还压着好几个 GB
TPLBuffer 是 PDF Library for Delphi 里每个解析器都要经过的读取器,它能包住 AnsiString、指针、字节数组或 TStream。它的四个扫描查询 DistanceToByte、DistanceToOtherByte、DistanceToAnyByte 和 DistanceToOtherBytes 都返回 Int64,以 64 KB 块读取源、寻找分隔符,并报告它离得多远而不移动逻辑位置。这类循环的自然写法是 Until ReadCount < BlockSize。对三种内存源这是正确的,因为 ReadIntoBuffer 在最后一块之前总是交付整块。对流源而言,这意味着扫描在第一次短读就放弃,报告分隔符不存在,其上的分词器随之把对象判到它并不结束的地方——这就是 TPLBuffer 循环到读取返回 0 为止的原因
// TPLBuffer.DistanceToByte 的扫描循环。
// 零是 TStream.Read 定义的唯一数据结束信号。
TempPosition := FPosition;
Try
Repeat
ReadCount := ReadIntoBuffer(@TempBuffer[0], BlockSize);
For TestPos := 0 To ReadCount - 1 Do
If TempBuffer[TestPos] = Value Then
Begin
Result := TotalSkipped + TestPos;
Exit;
End;
Inc(TotalSkipped, ReadCount);
Until ReadCount = 0;
Finally
FPosition := TempPosition; // peek 不得移动读取器
End;
钉死它的办法是一个 TMemoryStream 后代类,其 Read 覆盖把每次请求都截到两字节。把字符串 aaaaaX 包进去,把缓冲区位置设为 1,四个查询都必须报告到 X 的距离为 4、之后位置停在 1,并对不存在的字节报告 -1。在第一次短读就停下的扫描只看到两个字节,断定流已耗尽,返回 -1。finally 和循环条件同样要紧:从扫描内部 Exit 是正常的成功路径,逻辑位置在这条路径上也必须恢复,而不只是循环跑完时
一份源码、两个编译器、一套断言
这五条背后的纪律是:「Delphi 构建通过」只是关于 Delphi 的证据,不是关于源码的。一个值得拥有的跨编译器检查是让一个例程在两个编译器下各跑一遍:通过 TPDFlib 构建一个带压缩内容的两页文档,保存,再用 SaveQDFToFile 另存为 QDF,用 RepairQDFFile 修复 QDF,通过 EncryptFile 和来自 EncodePermissions 的权限掩码给明文文件加 AES-128 加密,然后在两个编译器上重新加载每个产物并检查相同的事项:页数为 2,标题保留,第二页文本从明文、修复后和加密文件中完整提取,错误密码被拒绝且 LastErrorCode 非零,EncryptionStrength 为 128,EncryptionAlgorithm 为 2,来自 GetUserPermissions 的各个权限位与编码时完全一致
比较的是归一化后的语义,而不是字节。加密会取随机盐,写入器会分配文档标识符,所以两个构建本就不该产出字节相同的文件;它们该产出含义相同的文件,检查也应在这个层面表述。QDF 这一环节因偏移陷阱而值得保留:一个两页无内容的 QDF 能通过页数检查、却在文本提取检查上失败——所以查后者。任何在一台编译器上无操作、在另一台上改变行为的变化——上面五条里有四条符合——都必须在两个编译器上通过相同的检查
同一移植的链接期那一半——让 Delphi 的 OMF 对象和 Free Pascal 的 COFF 期望达成一致——是另一个故事,见FPC Win32 OMF 到 COFF 的对象链接;同一 TIFF 读取器针对 BigTIFF 和分块文件的结构加固在内置 TIFF 解码器笔记里。本文的解码器随 PDF Library for Delphi 交付 Delphi、C++Builder 和 Free Pascal 版本——同一份源码应当在它瞄准的每个编译器上挣得相同的结果,而不是靠哪一个施舍