技術文章

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 剪掉。接著在目的地目錄裡建立名為 .pdflib-qdf- 加 GUID 加 .tmp 的暫存檔:Windows 上用 CreateFileW 加 CREATE_NEW,POSIX 上用 open(2) 加 O_CREAT or O_EXCL、模式 0600。兩種旗標都讓名稱已存在時建立失敗——不知怎麼抽到同一個 GUID 的兩個處理程序,也無法落到共用同一個控制代碼

修好的串流以 64 KiB 區塊透過 WriteBuffer 複製——短寫入時它丟例外,而不是回傳一個沒人檢查的計數。FlushFileBuffers 或 fsync(2) 把資料壓下去,控制代碼關閉。關閉刻意不丟例外:flush 已成功之後,關閉失敗對資料沒有威脅,那裡再丟例外只會把真正的寫入或 flush 錯誤藏起來,讓要診斷的人看不到。最後一步是更名

PDF Library for Delphi 裡 RepairQDFFile 發布輸出的四步:用 GetFullPathNameW 解析路徑兩次、以 CREATE_NEW 或 O_EXCL 建立 .pdflib-qdf 暫存檔讓競速的處理程序無法共用控制代碼、以 64 KiB 的 WriteBuffer 區塊複製並 flush,最後 MoveFileExW 帶 REPLACE_EXISTING 與 WRITE_THROUGH
每一步都非得前一步完成才肯繼續,暫存檔從結構上就住在目的地磁碟區,先刪後更名的窗口從不存在,finally 裡的清理不留下任何 .tmp 殘骸

更名正是多數自製安全存檔常式翻車的地方。Windows 上,暫存檔以帶 MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH 的 MoveFileExW 取代目標,同一磁碟區上這是一個檔案系統操作。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,新檔一出生就帶著舊檔的權限,更名不改變任何操作者會察覺的東西。讀取用的是帶 DACL_SECURITY_INFORMATION 的 GetFileSecurityW,緩衝大小取自第一次呼叫的 ERROR_INSUFFICIENT_BUFFER 回答。目的地還不存在時——獨立輸出路徑的正常情況——不傳描述元,新檔像任何新檔一樣從目錄繼承

QDF 發布寫入器為什麼在建立暫存檔前先複製目的地的 DACL:nil 描述元會繼承目錄較寬的權限、更名會悄悄放大存取,所以 GetFileSecurityW 先讀 DACL,缺 SE_DACL_PRESENT 位元或帶 EFS 屬性就以 305 停止發布,CreateFileW 則帶著舊權限出生
NTFS 安全性跟著檔案物件走、不跟著名稱走:把讀到的描述元當 lpSecurityAttributes 傳入,更名就不會改變操作者設定的任何東西,而每一道關卡都 fail closed,不做猜測

三種情況會讓發布停下來、而不是讓程式碼去猜,每一種都回報 LastErrorCode 305:

  • DACL 讀不到,例如呼叫帳戶對目的地缺 READ_CONTROL
  • 回傳的描述元沒有設 SE_DACL_PRESENT;把這種描述元交給 CreateFileW,Windows 會退回處理程序預設 DACL,改動沒有人要求改動的存取語意
  • 目的地帶 FILE_ATTRIBUTE_ENCRYPTED;暫存檔會是明文,把它更名蓋過 EFS 保護的檔案,等於發布一份未加密的替代品,取代使用者選擇加密的東西

EFS 是檔案系統功能,與 PDF 安全處理器毫無關係——後者見加密文件載入一文。不過失敗的形狀是同一種安靜降級,拒收它是正確的預設:修復偶爾在 EFS 共享上說不,是一張支援單;修復把使用者加密的檔案解密,是一次事故

自己寫 DACL 保留測試的話,有一個 Windows 細節容易弄錯。建受限測試檔時,請套用僅擁有者的 DACL,並在描述元控制裡明確設 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:被另一個控制代碼以無刪除共享方式佔住的目的地、唯讀目的地、缺目的地目錄,加上上述 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 複製與明確資料沖刷。落在暫存或除錯資料夾裡的 QDF 傾印,我認為這個份量的機械剛剛好;傾印要取代的是帶鎖定權限的檔案時,就讓它走修復路徑,或自己複製安全性描述元

原子式修復輸出、QDF 轉換呼叫與上述錯誤碼,都隨 PDF Library for Delphi 出貨,旁邊就是其他文章談過的交叉參照復原、增量更新與加密支援