PDFlibPas 處理加密 PDF 的錯誤密碼時,會捨棄剛剛失敗的 TPDFDocument,並為下一次嘗試建立完全新的物件;這項流程由 OnPassword 回呼(TPDFlibPasswordEvent)驅動,最多執行十六次後放棄。這有意背離了大多數 Delphi 開發者首先會採用的直覺:保留記憶體中現有的文件物件,提供修正後的密碼,然後在原處再次載入,而不是從零開始。PDFlibPas 在 v3.245.0 加入的重試迴圈採取相反立場,原因在於失敗的密碼嘗試會留下特定的狀態。這種情境很普通,多數以文件為主的 Delphi 應用程式最終都會遇到:輸入畫面接收 PDF,加密的檔案尾端強制顯示密碼對話方塊,操作人員不慎輸入錯誤字串,然後對話方塊再次出現讓人重試。這種使用者體驗本身並不特殊,因此背後的程式碼必須接受同一檔案的多個候選密碼,而且必須安全地完成,不讓被拒絕的嘗試把狀態洩漏到後續嘗試
為什麼不能在同一個文件物件上直接重試
在多次密碼嘗試之間重複使用 TPDFDocument 並不可行,因為失敗的嘗試已經在內部拆除該物件,而不是讓它停留在某種暫停且可繼續的狀態。開啟加密 PDF 代表要剖析交叉參照表、在底層來源上建立讀取器,並根據所提供的密碼建構加密處理器,這些工作都在 PDFlibPas 能夠測試密碼是否正確之前完成。當密碼證實錯誤時,文件的內部載入常式會在失敗退出時清理讀取器、交叉參照表和加密處理器,這正是應有的行為,也表示沒有半成品剖析器會留在那裡等待第二次呼叫提供修正後的密碼。無論如何都讓同一個物件進行另一次載入嘗試,失敗模式就會變成最難以偵錯的類型:錯誤從為不同且已失敗的剖析所建立的內部狀態中浮現,表面上沒有任何線索指向上游三次呼叫之前的密碼。PDFlibPas 完全避免這類問題,永遠不會嘗試復原已開啟失敗的文件物件;每次嘗試都會取得一個從未見過錯誤密碼的文件,包含讀取器和交叉參照表
OnPassword 回呼如何要求下一個密碼
當剛剛嘗試的密碼證實錯誤時,PDFlibPas 會透過 TPDFlib.LoadFromFile、LoadFromStream 和 LoadFromString 呼叫 TPDFlibPasswordEvent 回呼型別,並將三項資訊交給處理常式:即將執行的嘗試編號、可覆寫為下一個候選值的 Password 參數,以及預設為 false 的 Retry 旗標
TPDFlibPasswordEvent = procedure(Sender: TObject; AttemptNumber: Integer;
var Password: WideString; var Retry: Boolean) of object;
property OnPassword: TPDFlibPasswordEvent read FOnPassword write FOnPassword;
傳入原始 LoadFromFile 呼叫的密碼算作第一次嘗試,因此 OnPassword 首次觸發時,AttemptNumber 會是 2。保留 Retry 未設定,載入會以 LastErrorCode 404 清楚失敗;將它設為 true,PDFlibPas 就會使用處理常式剛寫入 Password 的值再次嘗試
重試迴圈內部:每次嘗試都建立新的 TPDFDocument
在內部,PDFlibPas 對 LoadFromFile、LoadFromStream 和 LoadFromString 都以相同方式回答物件生命週期問題:每次嘗試,包括第一次,都會建立全新的 TPDFDocument,使用該次嘗試所採用的密碼完成整個開啟流程,只有在密碼驗證成功時才保留物件。被拒絕嘗試的 TPDFDocument 會立即釋放,連同其讀取器、交叉參照表和加密處理器一起清除,下一次嘗試則從一個完全沒有歷史狀態的物件重新開始
// Simplified excerpt from inside LoadFromFile: every attempt gets a
// document that has never seen a previously rejected password. FileName,
// AttemptNumber and AttemptPassword come from the enclosing method.
Var
Doc: TPDFDocument;
LoadResult: TPLLoadResult;
Success: Boolean;
Begin
Success := False;
Repeat
Doc := TPDFDocument.Create;
Doc.DecodeMode := FDefaultDecodeMode;
Try
LoadResult := Doc.LoadFromFile(FileName, AttemptPassword);
Success := LoadResult = lrOkay;
if Success then
begin
FDocs.Add(Doc); // hand the verified document to the
Doc := nil; // caller's collection; skip the Free below
end;
Finally
Doc.Free; // a rejected attempt's reader, xref table
End; // and crypt handler are torn down right here
if Success or (LoadResult <> lrWrongPassword) then
Break; // success, or a non-password failure: stop
Inc(AttemptNumber);
Until not RequestPasswordRetry(AttemptNumber, AttemptPassword);
End;
清理區塊之前的 Doc := nil 一行,就是完整物件生命週期契約的全部內容。失敗的文件會讓其半成品剖析器狀態依設計一同消失,而成功的文件才是唯一會加入 FDocs 的文件,這是 TPDFlib 為呼叫端目前開啟的每份文件所維護的集合。被拒絕嘗試的任何內容都不會從重試迴圈外部看見:沒有半初始化的讀取器、沒有過期的頁數,也沒有使用錯誤金鑰建立的加密處理器
PDFlibPas 會重試錯誤密碼多少次
PDFlibPas 對單次 LoadFromFile、LoadFromStream 或 LoadFromString 呼叫允許總共十六次嘗試,並將傳入呼叫本身的密碼算作第一次嘗試。OnPassword 只會在第二次到第十六次嘗試時觸發,因此回呼最多被呼叫十五次;要求第十七次嘗試時,PDFlibPas 會拒絕,而且完全不呼叫處理常式。在任何時刻都讓 Retry 保持預設的 false,或十六次嘗試全部用盡仍沒有正確密碼時,LoadFromFile 會回傳 0,並將 LastErrorCode 設為 404,這是 PDFlibPas 表示密碼遭拒的代碼。這項上限不只是為了整潔:無限制的重試迴圈很容易把一次密碼輸入錯誤變成對執行載入的執行緒進行意外阻斷服務,尤其是當處理常式接上先前看過的密碼清單等自動化來源,而不是讓人逐一點選對話方塊時。PDFlibPas 也接受從處理常式內以 TPDFlib 執行個體呼叫 Abort,因為 Sender 會是同一個物件,這對密碼對話方塊後方的取消按鈕很有用,而且無論 Retry 設為何值,都會在下一次檢查時停止重試迴圈。因錯誤密碼以外的原因而失敗的載入,例如交叉參照表損壞,根本不會進入重試迴圈:PDFlibPas 會回報 LastErrorCode 401,並在第一次嘗試後停止,因為再多次猜密碼也無法修正結構損壞的檔案
檔案、資料流和字串的重試迴圈是否相同
在 LoadFromFile、LoadFromStream 和 LoadFromString 之間,OnPassword 回呼和十六次嘗試上限的行為完全相同,不過三個進入點在嘗試之間保留來源的方式不同。檔案路徑很容易重新存取,因為每次嘗試只要重新開啟指定檔案即可;字串來源已經以呼叫端自己的複本存在記憶體中,因此兩者都不需要呼叫端在嘗試之間提供協助。由呼叫端提供的資料流是唯一值得停下來說明的情況:LoadFromStream 會將資料流尋回位置零,並在第一次剖析嘗試前於內部複製它,因此後續每次嘗試以及背後新建的 TPDFDocument,都會從該內部複本重新播放,而不是從失敗剖析留下的資料流位置開始。將受密碼保護的文件以 TFileStream 或 TMemoryStream 交給 PDFlibPas 時,不需要在重試之間將它倒回;PDFlibPas 已經處理第一次失敗嘗試可能移動過的位置
將密碼重試納入文件輸入畫面
文件輸入工作流程是這個回呼最自然的使用位置,因為它正是 OnPassword 用來解決的問題形狀:檔案從應用程式外部到達,事前無法確定其密碼,而提供候選密碼的人需要不只一次猜測,且周邊程式碼不必自行在 LoadFromFile 外包一層重試迴圈
procedure TIntakeForm.SupplyPassword(Sender: TObject; AttemptNumber: Integer;
var Password: WideString; var Retry: Boolean);
var
Typed: string;
begin
// AttemptNumber counts from 2: the password already tried was attempt 1.
Typed := '';
Retry := InputQuery('Password required',
Format('Attempt %d of 16 - enter the document password', [AttemptNumber]), Typed);
if Retry then
Password := Typed;
// Retry is False when the operator cancels, which leaves
// LastErrorCode at 404 for the caller to report.
end;
procedure TIntakeForm.LoadInboundDocument;
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create;
try
Lib.OnPassword := SupplyPassword;
if Lib.LoadFromFile('inbound-invoice.pdf', '') = 1 then
RegisterIntakeDocument(Lib) // only a verified document reaches here
else
LogRejectedIntake('inbound-invoice.pdf', Lib.LastErrorCode);
finally
Lib.Free;
end;
end;
RegisterIntakeDocument 只有在 LoadFromFile 回傳 1 後才會收到 Lib,這代表該次互動中的某個密碼確實已通過檔案加密處理器的驗證;被拒絕的嘗試不會到達該行,半開啟的文件也不會。文件如此確認開啟後,接下來值得再次檢視其保護設定,而不是假定成功的密碼就是完整的安全性故事:稽核文件的 /Encrypt 字典實際宣告內容涵蓋讀取 PDFlibPas 在此類檔案載入後所公開的演算法、修訂版本和權限位元
密碼重試也是 PDFlibPas 在整個剖析層套用的更廣泛規範中的一個狹窄例子:尚未證明自身有效的檔案不會獲得任何信任,不論問題是要用哪個密碼解鎖,還是其中的長度欄位是否謊報所需緩衝區大小。強化 Pascal PDF 剖析器以防範惡意檔案涵蓋該規範的另一半,也就是將輸入 PDF 中的每個字型程式和影像資料流都視為敵意輸入的解碼器,而不是只因忘記密碼就被當成格式正確文件的資料
OnPassword 及其背後的重試迴圈屬於標準的 PDFlibPas Delphi 與 C++Builder PDF 函式庫,凡是已有 LoadFromFile、LoadFromStream 或 LoadFromString 的地方都能使用,不需要為只想再猜一次密碼的文件另行安裝模組或購買授權層級