PDF Library for Delphi 通过一个内部写入器 TPDFQDFFileWriter 发布 RepairQDFFile 的输出,它从不以写入方式打开目标:修复后的字节写进目标目录下一个以独占方式创建的临时文件,文件被 flush 并关闭,之后才用 Windows 上的 MoveFileExW 或 POSIX 上的 rename(2) 把它重命名到目标上。如果重命名之前出了任何问题,目标保留它原有的每一个字节,调用方看到 LastErrorCode 305。在内存里修复一份文档是修复功能里容易的那一半。把结果落到磁盘上、同时永远不让用户拿到一个零长度或写了一半的文件,是本文要讲的另一半
为什么一次失败的修复仍然能毁掉目标文件?
因为操作顺序错了。在 v3.539.13 之前,RepairQDFFile 用 PLCreateFileStream(OutputFileName, fmCreate) 打开输出,然后把那个流交给解析器。fmCreate 在打开时就截断,所以等到 QDF 扫描判定输入不可修复时,目标已经被清空了。就地修复——也就是 InputFileName 和 OutputFileName 是同一个路径——会把一次被拒的输入变成一次文件丢失。解析器本身行为良好:底层 PDFQDFRepair 函数在拒绝有歧义的标记时会让目标流原封不动。那份保护完全没有意义,因为公开 API 早在一次调用之前就把文件截断了
v3.539.13 的修法把修复挪进一个 TMemoryStream,只在 PDFQDFRepair 成功之后才打开输出。这堵住了解析失败这个洞,仅此而已。写阶段仍然是 fmCreate 后接 CopyFrom,所以磁盘写满、写到一半遇到共享冲突,或者在截断到最后一次 WriteBuffer 之间抛异常,仍然会留下一个损坏的目标。内存优先的修复防的是坏输入。落到磁盘需要自己的边界,v3.539.14 和 v3.539.15 就建了这么一条
// v3.539.12:输入还没被校验,目标就已经被截断
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
if PDFQDFRepair(Source, Output, QDFError) then // 此时说不行已经太晚
Result := 1;
finally
Output.Free;
end;
// v3.539.15:先在内存里修,再把字节交给发布写入器
Repaired := TMemoryStream.Create;
try
if not PDFQDFRepair(Source, Repaired, QDFError) then
Exit; // 目标从头到尾没被打开
Writer := TPDFQDFFileWriter.Create;
try
Writer.Save(Repaired, OutputFileName);
Result := 1;
finally
Writer.Free;
end;
finally
Repaired.Free;
end;
原子化发布到底保证了什么?
TPDFQDFFileWriter.Save 保证目标路径要么是完整的旧文件、要么是完整的新文件,绝不会是二者的混合,覆盖库自己能观察到的每一种失败。写入器用四步做到这一点,每一步除非上一步已经完成,否则都拒绝继续。第一步用 GetFullPathNameW 解析目标路径,调用两次并按返回的长度分配缓冲区,而不是假定 MAX_PATH,所以长路径不会被悄悄砍掉。第二步在目标目录里创建一个名为 .pdflib-qdf- 加一个 GUID 加 .tmp 的临时文件,在 Windows 上用带 CREATE_NEW 的 CreateFileW,在 POSIX 上用带 O_CREAT or O_EXCL 且模式 0600 的 open(2)。两个标志都会在名字已存在时让创建失败,所以两个进程用同一个 GUID 抢也不可能共享句柄。第三步通过 WriteBuffer 以 64 KiB 分块复制修复后的流,它在短写时抛异常,而不是返回一个没人检查的计数,然后调用 FlushFileBuffers 或 fsync(2) 并关闭句柄。第四步重命名
procedure TPDFQDFFileWriter.Flush(Target: TStream);
begin
if not FlushFileBuffers(THandleStream(Target).Handle) then
raise EWriteError.Create('Unable to flush QDF output');
end;
procedure TPDFQDFFileWriter.Publish(const TempFileName, FileName: WideString);
begin
// 不允许跨卷复制,也不允许先删掉目标
if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
raise EWriteError.Create('Unable to publish QDF output');
end;
重命名这一步正是大多数自制的「安全保存」例程悄悄出事的地方。带 MOVEFILE_REPLACE_EXISTING 的 MoveFileExW 在同一卷上以一次文件系统操作替换目标。写入器刻意不带 MOVEFILE_COPY_ALLOWED,因为跨卷移动会退化成先复制再删除,而那正是整套设计要避免的非原子序列。既然临时文件就在目标目录里,它按构造就在目标卷上。写入器也从不先删旧文件;先删后重命名这一对存在一个路径完全不存在的窗口,在这个窗口里崩一次就丢了文档。MOVEFILE_WRITE_THROUGH 要求这次调用在重命名落到磁盘之前不要返回,这与对数据的显式 flush 配套。在 POSIX 上,rename(2) 本来就保证新名字原子化替换任何已有文件,而放在同一个目录里让它不会以 EXDEV 失败。清理是对称的。临时名在每一条路径上都在 finally 块里删除,成功时这是 no-op,因为重命名已经把它消费掉了,失败时则删掉那个半成品,好让目录不堆积 .tmp 残渣。Tests\QDFFileRegression.inc 里的回归检查的正是这些:每一次注入失败之后,目标字节与原始一致,源字节与原始一致,目录里除了两个夹具之外空无一物
为什么临时文件会放宽 Windows 上的权限?
一个用 nil 安全描述符创建的文件,它的 DACL 继承自父目录,而不是它即将替换掉的那个文件。对一份全新的文档来说这是正确的默认,对就地修复来说是错的默认。假设运维已经把 contract.pdf 锁到只剩一个账号,用的是一条受保护、不继承的 DACL。它旁边新建的临时文件继承的是目录那套更宽的权限,而一旦它被重命名盖到 contract.pdf 上,重命名后的文件带的就是那套更宽的 DACL,因为 NTFS 安全信息跟着文件对象走,不跟着名字走。修复成功了,字节也是对的,而运维配置的访问控制悄悄没了。返回值里没有任何东西提示这一点
所以 PDF Library for Delphi 在创建临时文件之前先读出目标的 DACL,把它作为 lpSecurityAttributes 参数传给 CreateFileW,新文件一出生就带着旧文件的权限,重命名也就不会改变任何运维会注意到的东西。这次读取用带 DACL_SECURITY_INFORMATION 的 GetFileSecurityW,缓冲区大小取自第一次调用返回的 ERROR_INSUFFICIENT_BUFFER。有三种情况让写入器选择失败关闭而不是去猜。如果 DACL 读不出来,发布停下并抛 EWriteError,公开 API 把它映射成 305。如果拿回来的描述符没有置 SE_DACL_PRESENT,发布同样停下,因为把这样的描述符传给 CreateFileW 会让内核退回到进程默认 DACL,在没人要求的情况下改变访问语义。而如果目标带 FILE_ATTRIBUTE_ENCRYPTED,写入器直接拒绝:临时文件会是明文,而把明文文件重命名盖到一个受 EFS 保护的文件上,等于发布了一个未加密的替代品,替换掉用户特意在文件系统层面选择加密的东西。EFS 与 PDF 标准安全处理器无关,后者是加密文档加载那篇的主题,但失败模式是同一种悄悄降级
Attributes := GetFileAttributesW(PWideChar(Destination));
if Attributes <> INVALID_FILE_ATTRIBUTES then
begin
if (Attributes and FILE_ATTRIBUTE_ENCRYPTED) <> 0 then
raise EWriteError.Create('QDF replacement of an EFS encrypted file is not supported');
// 先给描述符定好大小,再只读它里面的 DACL 部分
if not GetFileSecurityW(PWideChar(Destination), DACL_SECURITY_INFORMATION,
@Security[0], SecuritySize, SecuritySize) then
raise EWriteError.Create('Unable to read QDF destination permissions');
if not QDFGetSecurityDescriptorControl(@Security[0], Control, Revision) or
((Control and SE_DACL_PRESENT) = 0) then
raise EWriteError.Create('QDF destination has no explicit DACL');
SecurityAttributes.lpSecurityDescriptor := @Security[0];
SecurityPointer := @SecurityAttributes; // 交给 CreateFileW / CREATE_NEW
end;
回归测试里有一个细节,如果你自己写类似的测试值得记住。为了搭出受限的夹具,测试会套一条仅所有者可用的 DACL,并且必须在描述符控制里显式设置 SE_DACL_PROTECTED;仅仅在 SetFileSecurityW 的 SecurityInformation 参数里传受保护标志,并不会把一个未受保护的描述符变成受保护的。之后的断言是:发布后的文件仍然报告受保护位和一条显式、非空的 DACL,对单独的输出路径和直接对着源文件就地修复这两种情况都成立
哪个 LastErrorCode 告诉你哪一步失败了?
RepairQDFFile 成功返回 1,任何失败返回 0,而 LastErrorCode 说明是哪个阶段拒绝的。一个读不出来的源——包括被另一个进程以独占锁持有的源——报 401;读取现在被包起来了,所以输入阶段的异常会映射成 401,而不是泄漏到写错误里去。无效或有歧义的 QDF 结构,比如同一个对象出现重复的流标记,报 PDFLIB_ERROR_QDF_REPAIR,也就是 107,而目标没被碰过,因为写入器根本没被构造出来。修复之后的一切,从创建临时文件到 flush 再到重命名,都报 PDFLIB_ERROR_QDF_WRITE,也就是 305。回归测试跑的是其中贴近现实的那些:目标被另一个不带删除共享的句柄打开、目标只读、目标目录不存在,以及写入器三个阶段各自通过注入失败。所有这些情形里返回值都是 0,错误码都是 305,事后也不存在任何新的或部分写入的目标。读错误码而不只是读返回值的这个习惯,正是诊断库里静默失败那篇里说的同一个习惯
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
// 就地修复:输入和输出是同一个路径
if Pdf.RepairQDFFile('edited.qdf.pdf', 'edited.qdf.pdf') = 1 then
Log('published; the previous bytes were replaced in one rename')
else
case Pdf.LastErrorCode of
401: Log('could not read the input; it was not modified');
107: Log('QDF structure rejected; the destination was never opened');
305: Log('write, flush or replace failed; the destination still holds its old bytes');
end;
finally
Pdf.Free;
end;
end;
保证到哪里为止
写入器承诺的是对进程能看到的那些失败保持一致性,而对它看不到的那些也是坦诚的。如果进程在创建临时文件和重命名之间被杀掉,finally 块永远不会执行,一个 .pdflib-qdf-<GUID>.tmp 文件会留在目录里;目标仍然完好,这才是要紧的性质,但那些残渣得你自己扫。断电同样在承诺之外:数据被 flush 了,重命名也是直写的,这已经是一个用户态库能要求的最好情况,但写入器不对目录项做 fsync,也不在文件系统提供的东西之上附加任何持久性承诺。另一个并发修改目标的写入器不会被发现,因为 DACL 和属性是在创建临时文件之前读的,重命名时没有任何东西再检查一遍。而一次成功的重命名会产生一个新的文件身份,所以备用数据流以及旧文件上归档位、隐藏位这类普通属性都无法存活;只有 DACL 是被刻意带过去的
更窄的那条边界是哪些 API 走这条路径。只有 RepairQDFFile 经过 TPDFQDFFileWriter。SaveQDFToFile 和 ConvertFileToQDF 仍然用 PLCreateFileStream(FileName, fmCreate) 打开输出,把 QDF 转换直接流进去,跟往流里追加更新那篇描述的增量路径一样,写进你交给它的任何流。那两个调用是从一份已经加载并校验过的文档产出一个新的调试产物,所以解析失败那个洞从来就不适用于它们,但它们也不继承基于重命名的发布。不要把本文读成「每一次 QDF 导出都是原子的」。它是某一个出口,是那个输入为不可信、手工编辑过的文件、输出又经常是同一个路径的出口,正是这个组合让它挣到了这套额外机制。证明这一切的故障注入很便宜,因为写入器的三个阶段 WriteData、Flush 和 Publish 都是 virtual 的。测试子类覆写其中一个,在真正的工作开始之后抛异常,对一个修复好的流调用 Save,然后断言异常传播出去、源和目标字节都没变、也不残留临时文件。没有钩住任何全局文件 API,没有碰任何真实用户文件,而这三个阶段与生产里发布可能失败的三种方式一一对应:磁盘写满、flush 被拒,或者重命名因为别人占着目标而被拒
RepairQDFFile API、它的原子化发布写入器以及 QDF 调试工作流的其余部分,都是 PDF Library for Delphi 的一部分,与本博客别处覆盖的交叉引用恢复、增量更新和加密功能并列