手工编辑一个 QDF 文件,给同一个对象塞进第二个流标记,然后用你能写出的最自然的 Delphi 代码跑一次就地修复。修复步骤发现损坏并拒绝。回头再看文件:0 字节。因为 TFileStream.Create(OutputFileName, fmCreate) 在校验器看到输入之前一行就把那个路径截断了。在内存里修复字节是整个工作的容易一半;把它们放回磁盘、且从不留下零长度、半写或权限不对的文件,是另一半,它有自己的失败模式
就地修复为什么会清空文件?
就地修复清空文件,是因为操作顺序错了:fmCreate 打开即截断,所以打开之后才来的任何拒绝都太晚。InputFileName 与 OutputFileName 是同一路径时,被拒的输入就是丢失的文档。再仔细的解析器也救不了你:拒绝歧义标记时它可以不动自己的输出流,但调用方在前一个调用里已经把目标毁了
// 最直白的写法:任何校验发生之前目标已被截断
Input := TMemoryStream.Create;
try
Input.LoadFromFile(InputFileName); // 与 OutputFileName 同一路径
Output := TFileStream.Create(OutputFileName, fmCreate); // 文件此刻已是 0 字节
try
if not MyRepairQDF(Input, Output) then // 你自己的校验器:想说不已经晚了
raise Exception.Create('QDF structure rejected');
finally
Output.Free;
end;
finally
Input.Free;
end;
常见的下一步尝试是先修复进 TMemoryStream,修复成功后才打开输出。这堵住了坏输入的洞,也仅此而已。写阶段仍是先截断后拷贝,磁盘满、中途的共享冲突、或截断与最后一次 WriteBuffer 之间的异常,照样把目标弄坏。解析与发布是两个独立的边界,各自需要各自的保护
在 PDF Library for Delphi 里,TPDFlib.RepairQDFFile(InputFileName, OutputFileName) 两头都管。它读入输入、完全在内存里修复,QDF 结构被拒时提前返回,目标端什么都没打开过。只有修复成功才进入发布,而发布从不以写方式打开目标
从修复后的字节到目标之间的四步
RepairQDFFile 承诺:对进程自己能观察到的每一种失败,目标路径上要么是完整的旧文件,要么是完整的新文件。它分四步走到那里,且任何一步都不在上一步完成之前开始。目标先用 GetFullPathNameW 解析成完整路径——调一次问所需长度,再调一次写进正好那么大的缓冲,长路径不会在 MAX_PATH 处被裁掉。接着在目标目录里以 CreateFileW 加 CREATE_NEW(Windows)或 open(2) 加 O_CREAT or O_EXCL 与 0600 模式(POSIX)创建名为 .pdflib-qdf- 加 GUID 加 .tmp 的临时文件。两个旗标都让重名时创建失败。两个进程碰巧抽到同一个 GUID 也不可能共享一个句柄
修复后的流以 64 KiB 分块经 WriteBuffer 拷贝——短写会抛异常,而不是返回一个没人查的计数。FlushFileBuffers 或 fsync(2) 把数据压下去,然后关闭句柄。关闭刻意不抛:flush 成功之后,关闭失败已不威胁数据,那里的异常反而会让真正要诊断的人看不到真实的写或 flush 错误。最后一步是重命名
多数自制的安全保存例程都折在重命名上。Windows 上临时文件经 MoveFileExW 加 MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH 替换目标,在同一卷上这是单次文件系统操作。MOVEFILE_COPY_ALLOWED 被刻意省去。跨卷移动会悄悄退化成先拷后删——恰是整个设计要避开的非原子序列——我宁可看到一次被拒的重命名,也不愿看到机器断电时做了一半的“成功”拷贝。临时文件就放在目标目录里,本来就在目标卷上。旧文件也从不先删;先删后改名的组合会留下路径不存在的窗口,窗口内崩溃就丢了文档。MOVEFILE_WRITE_THROUGH 保证调用在重命名落盘之前不返回。POSIX 上 rename(2) 本来就原子地替换既有名字,同目录放置让它碰不到 EXDEV
清理在每条路径的 finally 块里跑。重命名成功后临时名字已不存在,删除什么都不做;失败后它清掉半成品。目录里最终正好是调用之前那些文件
为什么临时文件会放宽 Windows 上的权限?
以 nil 安全描述符创建的文件从父目录继承 DACL;它即将替换的那个文件没有发言权。对全新文档这个默认是对的,对就地修复是错的。设想运维把 contract.pdf 用受保护的非继承 DACL 锁到单一账户。它旁边一个普通临时文件捡起目录那套更宽的权限,重命名之后发布的 contract.pdf 就带着那套宽 DACL,因为 NTFS 安全属于文件对象而不是名字。字节是对的,返回值是 1,运维设下的访问限制却悄悄消失了
所以 RepairQDFFile 在创建临时文件之前先读目标的 DACL,并经 lpSecurityAttributes 把它交给 CreateFileW,新文件一出生就带着旧文件的权限,重命名不改变任何运维能察觉的东西。读取用 GetFileSecurityW 加 DACL_SECURITY_INFORMATION,缓冲大小取自第一次调用的 ERROR_INSUFFICIENT_BUFFER 回答。目标尚不存在时——独立输出路径的常态——不传描述符,新文件像其他新文件一样从目录继承
三种情况会停下发布而不是让代码去猜,每种都报告 LastErrorCode 305:
- DACL 读不出来,比如调用账户对目标缺
READ_CONTROL - 返回的描述符没设
SE_DACL_PRESENT;把这种描述符交给CreateFileW会让 Windows 退回进程默认 DACL,改变没人要求改变的访问语义 - 目标带
FILE_ATTRIBUTE_ENCRYPTED;临时文件会是明文,把它重命名到 EFS 保护的文件上,等于发布一个用户选择加密之物的未加密替身
EFS 是文件系统特性,与 PDF 安全处理器毫无关系——那在加密文档加载一文里讲。但失败形状是同一类安静降级,拒绝它是正确的默认。修复偶尔在 EFS 共享上说声“不”,那是一张支持工单;修复把用户加密过的文件解密了,那是一起事故
自己写 DACL 保留测试的话,有个 Windows 细节容易搞错。构造受限测试文件时,要显式套用 owner-only DACL 并在描述符 control 里设 SE_DACL_PROTECTED;把 PROTECTED_DACL_SECURITY_INFORMATION 传给 SetFileSecurityW 并不会把未保护的描述符变成受保护的。之后验证发布的文件仍报告受保护位和显式非空 DACL,独立输出路径和就地修复各测一遍
哪个 LastErrorCode 告诉你哪一步失败了?
RepairQDFFile 成功返回 1、失败返回 0,TPDFlib.LastErrorCode 指出是哪一步拒绝的。输入不存在、打不开或超过 2 GB 报 401,读入时的异常也映射到 401 而不是漏进写代码。无效或歧义的 QDF 结构——比如那个重复的流标记——报 PDFLIB_ERROR_QDF_REPAIR(107);目标从未被打开。修复之后的一切,从解析路径到 flush 和重命名,报 PDFLIB_ERROR_QDF_WRITE(305)。现实的写失败全落在 305:被另一个无 delete 共享的句柄占着的目标、只读目标、缺失的目标目录,加上前文的 DACL 与 EFS 拒绝。每个 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');
PDFLIB_ERROR_QDF_REPAIR:
Log('QDF structure rejected; the destination was never opened');
PDFLIB_ERROR_QDF_WRITE:
Log('write, flush or replace refused; the destination keeps its old bytes');
end;
finally
Pdf.Free;
end;
end;
305 路径在自己的测试工程里就能复现,不用挂钩任何文件 API。Delphi 的 fmShareDenyNone 打开文件带读写共享但不带 FILE_SHARE_DELETE,Windows 拒绝把任何东西重命名到这样占着的文件上。让这种句柄在调用期间保持打开,重命名就在最后一步失败——数据已写好并刷盘之后——这恰是你最想看到被处理的那种情况
var
Before: TBytes;
Holder: TFileStream;
Search: TSearchRec;
begin
Before := TFile.ReadAllBytes(Target); // System.IOUtils
Holder := TFileStream.Create(Target, fmOpenRead or fmShareDenyNone);
try
Assert(Pdf.RepairQDFFile(Source, Target) = 0);
Assert(Pdf.LastErrorCode = PDFLIB_ERROR_QDF_WRITE);
finally
Holder.Free;
end;
// 这些检查是给你自己测试的建议,不是库调用
Assert(BytesEqual(Before, TFile.ReadAllBytes(Target))); // BytesEqual:你的辅助函数
// 不得留下任何临时文件
if FindFirst(ExtractFilePath(Target) + '.pdflib-qdf-*.tmp',
faAnyFile, Search) = 0 then
try
Assert(False, 'temporary file left behind: ' + Search.Name);
finally
FindClose(Search);
end;
end;
养成看代码而不单信 0 的习惯——诊断静默失败一文在 API 其余部分走的是同一个模式
保证到哪里为止
承诺覆盖进程能看到的失败。如果进程在创建临时文件与重命名之间被杀,finally 块不会运行,目录里留下一个 .pdflib-qdf-<GUID>.tmp。目标仍然完好——这才是要紧的——但清扫这些残渣是你的事。断电是文件系统问题:数据已刷、重命名是 write-through,POSIX 上目录项并没有单独同步,持久性就是文件系统在这之上提供的一切
成功的重命名还产出一个新的文件对象,旧对象的几个属性不会跟着走。DACL 被刻意带过去。所有权没有——只读了 DACL_SECURITY_INFORMATION——发布的文件归执行修复的账户所有。临时文件以 FILE_ATTRIBUTE_NORMAL 创建,意味着隐藏、归档等属性位从头开始,旧文件的备用数据流也没了。POSIX 上发布的文件保留临时文件的 0600 模式;组需要读权限就事后调 chmod。目标的属性与 DACL 在临时文件存在之前读取,所以同一路径上的第二个写入者由你自己协调
SaveQDFToFile 与 ConvertFileToQDF 直接写
RepairQDFFile 是唯一经临时文件发布的 QDF 入口。SaveQDFToFile 和 ConvertFileToQDF 以 fmCreate 打开输出、把转换直接流进去,就像向流追加更新一文里的增量路径写进你给的任何流那样。这个划分是有意的。那两个调用把一份已加载并验证过的文档变成新的调试产物,不存在会在半路被拒的不可信输入,输出路径通常也是新文件。RepairQDFFile 接手的是手工编辑过的、不可信的文件,而且常常被指回同一路径;正是这个组合配得上那些额外的机制
真想对 QDF 转储做原子替换时,自己写个短包装就够了:
// 你自己的辅助函数:把 QDF 写到目标旁边,再一次重命名完成替换
function SaveQDFReplacing(Pdf: TPDFlib; const FileName: string): Boolean;
var
ID: TGUID;
TempName: string;
begin
CreateGUID(ID);
TempName := ExtractFilePath(ExpandFileName(FileName)) +
'.myapp-qdf-' + GUIDToString(ID) + '.tmp';
try
Result := (Pdf.SaveQDFToFile(TempName) = 1) and
MoveFileExW(PWideChar(WideString(TempName)), PWideChar(WideString(FileName)),
MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH);
finally
if FileExists(TempName) then
DeleteFile(TempName); // 只在出问题时才存在
end;
end;
这个版本省去了 RepairQDFFile 做的 DACL 拷贝和显式数据 flush。落在临时或调试目录里的 QDF 转储,我认为这些机制刚好够用;如果转储要替换一个权限锁死的文件,走修复路径,或者自己拷贝安全描述符
原子修复输出、上文这些 QDF 转换调用和错误码,都随 PDF Library for Delphi 发布,与这里其他文章讨论的交叉引用恢复、增量更新和加密支持放在一起