技術文章

Delphi 中的原子式 PDF 修復輸出:更名與 DACL 安全

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 建了一個

PDF Library for Delphi 的 RepairQDFFile 如何停止摧毀自己的目標:v3.539.12 用 PLCreateFileStream 與 fmCreate 開啟輸出,在 PDFQDFRepair 都還來不及拒絕輸入之前就截斷它;v3.539.13 改為先修進 TMemoryStream;v3.539.15 把位元組交給 TPDFQDFFileWriter 做原子式發布
剖析失敗的修法與發布的修法是兩條不同的邊界:記憶體優先的修復防的是壞輸入,而寫入器的存在,是為了讓磁碟滿或寫到一半失敗再也不能留下損壞的目的檔
// 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 上用 CreateFileW 搭配 CREATE_NEW,POSIX 上用 open(2) 搭配 O_CREAT or O_EXCL 與 mode 0600。兩個旗標都讓建立動作在同名檔案已存在時失敗,所以兩個行程就算衝到同一個 GUID 也無法共用控制代碼。第三步用 WriteBuffer 以 64 KiB 區塊複製修好的串流,它在短寫入時丟例外而不是回傳一個沒人檢查的計數,然後呼叫 FlushFileBuffers 或 fsync(2) 並關閉控制代碼。第四步是更名

PDF Library for Delphi 中 TPDFQDFFileWriter.Save 的四個原子步驟:用 GetFullPathNameW 解析路徑兩次,以 CREATE_NEW 或 O_EXCL 建立 .pdflib-qdf 暫存檔讓競爭行程無法共用控制代碼,用 64 KiB 的 WriteBuffer 區塊複製並 flush,最後以帶 REPLACE_EXISTING 與 WRITE_THROUGH 的 MoveFileExW 更名
每一步在前一步完成之前都拒絕繼續,暫存檔在結構上必然位於目的磁碟區,先刪後更名的空窗期從不存在,而 finally 裡的清理不會留下任何 .tmp 殘骸
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;

更名這一步,是大多數自家手寫「安全儲存」常式悄悄崩掉的地方。MoveFileExW 搭配 MOVEFILE_REPLACE_EXISTING 會在同一個磁碟區上以一次檔案系統操作取代目標。寫入器刻意不加 MOVEFILE_COPY_ALLOWED,因為跨磁碟區搬移會退化成先複製再刪除,而那正是整個設計要避開的非原子序列。既然暫存檔就放在目的目錄裡,它在結構上必然位於目的磁碟區。寫入器也從不先刪掉舊檔;先刪再更名這對操作存在一個路徑完全不存在的空窗期,而在那個空窗期內當掉就會丟掉文件。MOVEFILE_WRITE_THROUGH 要求這個呼叫在更名落盤之前不要返回,這與資料的明確 flush 相互搭配。在 POSIX 上,rename(2) 本身就保證新名字會原子式取代任何既有檔案,而同目錄的擺放位置則讓它不會以 EXDEV 失敗。清理則是對稱的。暫存檔名在每一條路徑上都會於 finally 區塊中被移除;成功時那是無作用的,因為更名已經把它消耗掉了,失敗時則移除那個不完整的檔案,讓目錄不會累積 .tmp 殘骸。Tests\QDFFileRegression.inc 裡的回歸測試檢查的正是這件事:每一次注入失敗之後,目的檔位元組與原本相同、來源位元組與原本相同,而目錄裡除了那兩個測試檔案之外什麼都沒有

為什麼暫存檔會讓 Windows 上的權限變鬆

以 nil 安全性描述元建立的檔案,是從父目錄繼承它的 DACL,而不是從它即將取代的那個檔案。對一份全新的文件來說那是正確的預設值,對就地修復來說則是錯的。假設有位操作者把 contract.pdf 鎖到只剩單一帳號,用的是受保護、不繼承的 DACL。旁邊那個暫存檔會繼承目錄較寬鬆的權限,而一旦它被更名覆蓋 contract.pdf,更名後的檔案就帶著那個寬鬆的 DACL,因為 NTFS 的安全性跟著檔案物件走,不跟著名字走。修復成功了、位元組也對,而操作者設定的存取控制悄悄消失了。回傳值裡沒有任何線索暗示這件事

所以 PDF Library for Delphi 會在建立暫存檔之前先讀出目的檔的 DACL,並把它當成 lpSecurityAttributes 引數傳給 CreateFileW,讓新檔案一出生就帶著舊檔案的權限,更名之後也不會改變任何操作者會注意到的事。讀取用的是 GetFileSecurityW 搭配 DACL_SECURITY_INFORMATION,並依第一次呼叫的 ERROR_INSUFFICIENT_BUFFER 結果決定緩衝區大小。有三種情況會讓寫入器選擇失敗關閉而不是猜。如果 DACL 讀不到,發布停住並丟出 EWriteError,公開 API 把它映射成 305。如果描述元回來時沒有設起 SE_DACL_PRESENT,發布也停住,因為把這樣的描述元傳給 CreateFileW,會讓核心退回使用行程預設的 DACL,在沒人要求的情況下改變存取語意。而如果目標帶著 FILE_ATTRIBUTE_ENCRYPTED,寫入器直接拒絕:暫存檔會是明文,而把明文檔案更名覆蓋一個受 EFS 保護的檔案,等於發布一份未加密的替代品,去取代使用者選擇在檔案系統層加密的東西。EFS 與 PDF 標準安全性處理器無關,後者是加密文件載入這篇的主題,但失效模式是同一種悄悄降級

為什麼 QDF 發布寫入器在建立暫存檔之前先複製目的檔的 DACL:nil 描述元會繼承目錄較寬鬆的權限,讓更名悄悄放寬存取,所以改用 GetFileSecurityW 讀出 DACL;缺少 SE_DACL_PRESENT 位元或帶 EFS 屬性都會以 305 停止發布,而 CreateFileW 讓新檔案一出生就帶著舊權限
NTFS 的安全性跟著檔案物件走,不跟著名字走:把讀到的描述元當成 lpSecurityAttributes 傳入,讓更名不改變操作者設定的任何事,而每一道閘門都選擇失敗關閉而不是猜
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 引數裡傳入受保護旗標,並不能把未受保護的描述元變成受保護的。之後的斷言是:發布後的檔案仍然回報受保護位元與明確且非 null 的 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 過,更名也是 write-through,這已經是使用者模式程式庫能要求的最好待遇,但寫入器沒有對目錄項做 fsync,也沒有在檔案系統所提供的之外加上任何持久性宣稱。另一個同時修改目的檔的寫入器不會被偵測到,因為 DACL 與屬性是在建立暫存檔之前讀的,而更名時沒有任何東西重新檢查它們。而一次成功的更名會產生新的檔案身分,所以替代資料流以及舊檔上的 archive 或 hidden 這類普通屬性都不會存活,只有 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,與本部落格其他文章談到的交叉參照修復、增量更新與加密功能並列