技术文章

Delphi 里原子化的 PDF 修复输出:重命名与 DACL 安全

手工编辑一个 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 结构被拒时提前返回,目标端什么都没打开过。只有修复成功才进入发布,而发布从不以写方式打开目标

Delphi 里发布 QDF 修复的三种方式:用 fmCreate 打开输出会在修复拒绝输入之前截断它;先修复进 TMemoryStream 只防坏输入;RepairQDFFile 则经临时文件与原子重命名发布修复后的字节
解析安全与发布安全是两个不同的边界:内存优先的修复防坏输入,而写入器存在的意义是磁盘满或写到一半的失败不能弄坏目标

从修复后的字节到目标之间的四步

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 错误。最后一步是重命名

PDF Library for Delphi 里 TPDFQDFFileWriter.Save 的四个原子步骤示意图:用 GetFullPathNameW 调两次解析路径,用 CREATE_NEW 或 O_EXCL 创建 .pdflib-qdf 临时文件让竞争的进程无法共享句柄,以 64 KiB 的 WriteBuffer 分块复制并 flush,最后用带 REPLACE_EXISTING 与 WRITE_THROUGH 的 MoveFileExW 收尾
每一步除非上一步已经完成否则都拒绝继续,临时文件按构造就在目标卷上,先删后建的时间窗从不出现,而 finally 里的清理不会留下 .tmp 残渣

多数自制的安全保存例程都折在重命名上。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 回答。目标尚不存在时——独立输出路径的常态——不传描述符,新文件像其他新文件一样从目录继承

QDF 发布写入器为什么在创建临时文件之前先复制目标的 DACL:nil 描述符会继承目录那套更宽的权限,重命名会悄悄放宽访问;所以 GetFileSecurityW 先读出 DACL,缺少 SE_DACL_PRESENT 位或带 EFS 属性都会以 305 停止发布,而 CreateFileW 创建出的文件一出生就带着旧权限
NTFS 安全信息跟着文件对象走而不是跟着名字走:把读到的描述符当作 lpSecurityAttributes 传进去,重命名就不会改变运维配置的任何东西,而每道闸门都是失败关闭而不是猜测

三种情况会停下发布而不是让代码去猜,每种都报告 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 发布,与这里其他文章讨论的交叉引用恢复、增量更新和加密支持放在一起