PDF Library for Delphi phát hành đầu ra của RepairQDFFile qua một bộ ghi nội bộ, TPDFQDFFileWriter, thứ không bao giờ mở đích để ghi: các byte đã sửa đi vào một tệp tạm được tạo độc quyền trong cùng thư mục, tệp được flush rồi đóng, và chỉ sau đó mới được rename đè lên đích bằng MoveFileExW trên Windows hay rename(2) trên POSIX. Nếu có bất cứ thứ gì hỏng trước bước rename, đích vẫn giữ nguyên từng byte nó đang có, và bên gọi nhận LastErrorCode 305. Sửa một tài liệu trong bộ nhớ là nửa dễ của một tính năng sửa chữa. Đưa kết quả xuống đĩa mà không bao giờ để người dùng nhận một tệp dài bằng không hay ghi dở là nửa mà bài viết này nói tới
Vì sao một lần sửa thất bại vẫn có thể phá hủy tệp đích?
Vì thứ tự các thao tác sai. Trước v3.539.13, RepairQDFFile mở đầu ra bằng PLCreateFileStream(OutputFileName, fmCreate) rồi giao stream đó cho parser. fmCreate cắt cụt tệp ngay khi mở, nên tới lúc phép quét QDF quyết định đầu vào không sửa được thì đích đã bị làm rỗng từ trước. Kiểu sửa tại chỗ, nơi InputFileName và OutputFileName là cùng một đường dẫn, đã biến một đầu vào bị từ chối thành một tệp bị mất. Bản thân parser thì cư xử đúng: hàm PDFQDFRepair ở tầng thấp vẫn giữ nguyên stream đích khi nó từ chối các marker mơ hồ. Nhưng sự bảo vệ đó chẳng có ý nghĩa gì, vì API công khai đã cắt cụt tệp từ một lệnh gọi trước đó
Bản sửa v3.539.13 chuyển việc sửa chữa vào một TMemoryStream và chỉ mở đầu ra sau khi PDFQDFRepair đã thành công. Cách đó bịt được lỗ hổng lúc phân tích và không bịt gì thêm. Giai đoạn ghi vẫn là fmCreate rồi CopyFrom, nên một tình huống đầy đĩa, một lỗi chia sẻ tệp xảy ra giữa chừng, hay một exception giữa lúc cắt cụt và lệnh WriteBuffer cuối cùng vẫn để lại một đích hỏng. Sửa trong bộ nhớ trước bảo vệ khỏi đầu vào xấu. Việc phát hành xuống đĩa cần một ranh giới riêng, và v3.539.14 cùng v3.539.15 đã dựng nên ranh giới đó
// v3.539.12: đích bị cắt cụt trước khi đầu vào được kiểm tra
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
if PDFQDFRepair(Source, Output, QDFError) then // đã quá muộn để nói không
Result := 1;
finally
Output.Free;
end;
// v3.539.15: sửa trong bộ nhớ, rồi giao các byte cho bộ ghi phát hành
Repaired := TMemoryStream.Create;
try
if not PDFQDFRepair(Source, Repaired, QDFError) then
Exit; // đích chưa từng được mở
Writer := TPDFQDFFileWriter.Create;
try
Writer.Save(Repaired, OutputFileName);
Result := 1;
finally
Writer.Free;
end;
finally
Repaired.Free;
end;
Phát hành nguyên tử thật ra bảo đảm điều gì?
TPDFQDFFileWriter.Save bảo đảm rằng đường dẫn đích hoặc là tệp cũ nguyên vẹn hoặc là tệp mới nguyên vẹn, không bao giờ là thứ trộn lẫn, với mọi lỗi mà chính thư viện quan sát được. Bộ ghi làm việc này qua bốn bước, mỗi bước từ chối đi tiếp nếu bước trước chưa xong. Thứ nhất, nó phân giải đích bằng GetFullPathNameW, gọi hai lần và cấp buffer theo độ dài trả về thay vì mặc định MAX_PATH, nhờ đó đường dẫn dài không bị cắt âm thầm. Thứ hai, nó tạo một tệp tạm tên .pdflib-qdf- cộng một GUID cộng .tmp trong thư mục đích, dùng CreateFileW với CREATE_NEW trên Windows và open(2) với O_CREAT or O_EXCL cùng mode 0600 trên POSIX. Cả hai cờ đều khiến thao tác tạo thất bại nếu tên đã tồn tại, nên hai tiến trình đua nhau trên cùng một GUID không thể chia sẻ một handle. Thứ ba, nó copy stream đã sửa theo từng khối 64 KiB qua WriteBuffer, hàm này raise khi ghi thiếu thay vì trả về một con số chẳng ai kiểm, rồi gọi FlushFileBuffers hay fsync(2) và đóng handle. Thứ tư, nó rename
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
// Không cho phép copy khác ổ đĩa hay xóa đích trước
if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
raise EWriteError.Create('Unable to publish QDF output');
end;
Bước rename là chỗ phần lớn các thủ tục “lưu an toàn” tự chế âm thầm vỡ. MoveFileExW với MOVEFILE_REPLACE_EXISTING thay thế đích trong một thao tác hệ thống tệp duy nhất khi cùng ổ đĩa. Bộ ghi cố ý bỏ MOVEFILE_COPY_ALLOWED, vì một lần di chuyển khác ổ đĩa sẽ thoái hóa thành copy rồi xóa, đúng cái chuỗi phi nguyên tử mà toàn bộ thiết kế này sinh ra để tránh. Vì tệp tạm nằm trong thư mục đích nên nó ở đúng ổ đĩa của đích theo cấu trúc. Bộ ghi cũng không bao giờ xóa tệp cũ trước; một cặp xóa rồi rename tạo ra khoảng thời gian mà đường dẫn không hề tồn tại, và một cú sập nằm trong khoảng đó sẽ làm mất tài liệu. MOVEFILE_WRITE_THROUGH yêu cầu lệnh gọi không trả về cho tới khi thao tác rename đã xuống tới đĩa, đi cặp với việc flush dữ liệu tường minh. Trên POSIX, rename(2) vốn đã bảo đảm tên mới thay thế nguyên tử mọi tệp đang tồn tại, và việc đặt tệp tạm cùng thư mục giữ cho nó không thất bại với EXDEV. Phần dọn dẹp thì đối xứng. Tên tạm được gỡ trong khối finally trên mọi đường đi, thành công thì đó là no-op vì thao tác rename đã tiêu thụ nó rồi, còn thất bại thì nó gỡ tệp dở dang để thư mục không tích tụ rác .tmp. Bài kiểm thử hồi quy trong Tests\QDFFileRegression.inc kiểm tra đúng điều đó: sau mọi lỗi được tiêm vào, byte của đích khớp với bản gốc, byte của nguồn khớp với bản gốc, và thư mục không chứa gì ngoài hai fixture
Vì sao một tệp tạm lại nới lỏng quyền trên Windows?
Một tệp được tạo với security descriptor nil sẽ thừa hưởng DACL từ thư mục cha chứ không từ tệp mà nó sắp thay thế. Đó là mặc định đúng cho một tài liệu hoàn toàn mới và là mặc định sai cho một lần sửa tại chỗ. Giả sử một quản trị viên đã khóa contract.pdf chỉ còn một tài khoản duy nhất với DACL được bảo vệ, không thừa hưởng. Một tệp tạm nằm cạnh nó sẽ thừa hưởng quyền rộng hơn của thư mục, và khi nó được rename đè lên contract.pdf thì tệp sau khi đổi tên mang DACL rộng đó, vì bảo mật NTFS đi theo đối tượng tệp chứ không đi theo cái tên. Lần sửa thành công, các byte đúng, và quyền truy cập mà quản trị viên đã cấu hình biến mất trong im lặng. Không có gì trong giá trị trả về gợi ý về điều đó
Vì thế PDF Library for Delphi đọc DACL của đích trước khi tạo tệp tạm và truyền nó vào làm tham số lpSecurityAttributes cho CreateFileW, để tệp mới sinh ra với đúng quyền của tệp cũ và thao tác rename không đổi bất cứ thứ gì quản trị viên có thể nhận ra. Phép đọc dùng GetFileSecurityW với DACL_SECURITY_INFORMATION, lấy kích thước buffer từ kết quả ERROR_INSUFFICIENT_BUFFER của lần gọi đầu. Ba điều kiện khiến bộ ghi thất bại theo hướng đóng kín thay vì đoán. Nếu không đọc được DACL, việc phát hành dừng với một EWriteError, và API công khai ánh xạ nó thành 305. Nếu descriptor trả về mà bit SE_DACL_PRESENT không được bật, việc phát hành cũng dừng, vì truyền một descriptor như vậy cho CreateFileW sẽ để kernel rơi về DACL mặc định của tiến trình và thay đổi ngữ nghĩa truy cập mà không ai yêu cầu. Và nếu đích mang FILE_ATTRIBUTE_ENCRYPTED, bộ ghi từ chối thẳng: tệp tạm sẽ là văn bản thuần, và rename một tệp thuần đè lên một tệp được EFS bảo vệ là phát hành một bản thay thế không mã hóa cho thứ mà người dùng đã chọn mã hóa ở tầng hệ thống tệp. EFS không liên quan gì tới các security handler chuẩn của PDF, vốn là chủ đề của bài về nạp tài liệu đã mã hóa, nhưng kiểu hỏng hóc thì cùng loại âm thầm hạ cấp
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');
// lấy kích thước descriptor, rồi chỉ đọc phần DACL của nó
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; // được truyền cho CreateFileW / CREATE_NEW
end;
Có một chi tiết từ bài kiểm thử hồi quy đáng ghi nhớ nếu bạn tự viết một bài tương tự. Để dựng fixture bị hạn chế quyền, bài kiểm thử áp một DACL chỉ dành cho chủ sở hữu và phải đặt SE_DACL_PROTECTED trong control của descriptor một cách tường minh; chỉ truyền cờ protected trong tham số SecurityInformation của SetFileSecurityW không biến một descriptor chưa được bảo vệ thành descriptor được bảo vệ. Khẳng định sau đó là tệp đã phát hành vẫn báo bit protected cùng một DACL tường minh, khác null, cho cả trường hợp đường dẫn đầu ra riêng lẫn trường hợp sửa đè ngay trên tệp nguồn
LastErrorCode nào cho biết cái gì đã hỏng?
RepairQDFFile trả về 1 khi thành công và 0 khi thất bại, còn LastErrorCode cho biết giai đoạn nào đã từ chối. Một nguồn không đọc được, kể cả nguồn bị tiến trình khác giữ bằng khóa độc quyền, báo 401; phép đọc giờ được bọc để một exception trong lúc đọc vào ánh xạ thành 401 thay vì rò rỉ sang lỗi ghi. Cấu trúc QDF không hợp lệ hoặc mơ hồ, ví dụ một stream marker bị lặp cho cùng một object, báo PDFLIB_ERROR_QDF_REPAIR, tức 107, và đích chưa bị chạm tới vì bộ ghi thậm chí chưa được tạo. Mọi thứ sau khâu sửa chữa, từ việc tạo tệp tạm tới flush rồi rename, đều báo PDFLIB_ERROR_QDF_WRITE, tức 305. Bài kiểm thử hồi quy chạy qua những tình huống thực tế: một đích bị handle khác mở mà không chia sẻ quyền xóa, một đích chỉ đọc, một thư mục đích không tồn tại, và từng giai đoạn trong ba giai đoạn của bộ ghi hỏng do được tiêm lỗi. Trong mọi trường hợp, giá trị trả về là 0, mã là 305, và sau đó không tồn tại đích mới hay đích dở dang nào. Thói quen đọc mã lỗi thay vì chỉ đọc giá trị trả về cũng chính là thói quen được mô tả trong bài về chẩn đoán các lỗi thầm lặng trong thư viện
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
// Sửa tại chỗ: cùng một đường dẫn vừa là đầu vào vừa là đầu ra
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;
Bảo đảm dừng lại ở đâu
Bộ ghi cam kết tính nhất quán trước những lỗi mà tiến trình nhìn thấy được, và trung thực về những lỗi nó không thấy. Nếu tiến trình bị kill giữa lúc tạo tệp tạm và lúc rename, khối finally không bao giờ chạy và một tệp .pdflib-qdf-<GUID>.tmp bị bỏ lại trong thư mục; đích vẫn nguyên vẹn, và đó mới là tính chất đáng quan tâm, nhưng đống rác thì bạn phải tự dọn. Mất điện cũng nằm ngoài lời hứa: dữ liệu đã được flush và thao tác rename là write-through, tức mức tốt nhất mà một thư viện chạy ở user mode có thể đòi, nhưng bộ ghi không fsync mục thư mục và không đưa ra cam kết bền vững nào cao hơn những gì hệ thống tệp cung cấp. Một bộ ghi thứ hai sửa đích cùng lúc sẽ không bị phát hiện, vì DACL và các thuộc tính được đọc trước khi tệp tạm được tạo và không có gì kiểm lại chúng ở thời điểm rename. Và một lần rename thành công tạo ra một định danh tệp mới, nên các alternate data stream cùng những thuộc tính thông thường như bit archive hay hidden trên tệp cũ không sống sót; chỉ DACL được mang sang một cách có chủ đích
Ranh giới hẹp hơn nằm ở chỗ API nào thật ra dùng con đường này. Chỉ RepairQDFFile đi qua TPDFQDFFileWriter. SaveQDFToFile và ConvertFileToQDF vẫn mở đầu ra bằng PLCreateFileStream(FileName, fmCreate) và đổ thẳng kết quả chuyển đổi QDF vào đó, đúng như đường cập nhật tăng dần mô tả trong bài về ghi thêm cập nhật vào stream ghi vào bất kỳ stream nào bạn đưa cho nó. Hai lệnh gọi đó đang tạo ra một artifact gỡ lỗi mới từ một tài liệu vốn đã được nạp và kiểm tra, nên lỗ hổng lúc phân tích chưa từng áp cho chúng, nhưng chúng cũng không thừa hưởng kiểu phát hành dựa trên rename. Đừng đọc bài này thành “mọi lần xuất QDF đều nguyên tử”. Đây là một lối ra, lối có đầu vào là một tệp không đáng tin do người ta sửa tay và có đầu ra thường xuyên trùng đúng đường dẫn đó, và chính sự kết hợp ấy mới khiến nó được trang bị thêm. Việc tiêm lỗi để chứng minh tất cả những điều này rẻ vì ba giai đoạn của bộ ghi, WriteData, Flush và Publish, đều là virtual. Lớp con dùng cho kiểm thử ghi đè một trong số đó để raise sau khi công việc thật đã bắt đầu, gọi Save trên một stream đã sửa, rồi khẳng định rằng exception lan ra, rằng byte của nguồn lẫn đích không đổi, và rằng không còn tệp tạm nào. Không hook API tệp toàn cục nào, không đụng vào tệp thật nào của người dùng, và ba giai đoạn ánh xạ một-một với ba cách một lần phát hành có thể hỏng trong môi trường thật: đĩa đầy, flush bị từ chối, hoặc rename bị từ chối vì người khác đang giữ đích
API RepairQDFFile, bộ ghi phát hành nguyên tử của nó và phần còn lại của quy trình gỡ lỗi QDF đều thuộc PDF Library for Delphi, bên cạnh các tính năng khôi phục cross-reference, cập nhật tăng dần và mã hóa đã được bàn ở những bài khác trên blog này