บทความเทคนิค

เขียนผลการซ่อม PDF แบบ atomic บน Delphi: rename กับ DACL

PDF Library for Delphi เผยแพร่ output ของ RepairQDFFile ผ่านตัวเขียนภายในชื่อ TPDFQDFFileWriter ที่ไม่เคยเปิดปลายทางเพื่อเขียน: ไบต์ที่ซ่อมแล้วถูกเขียนลงไฟล์ชั่วคราวที่สร้างแบบ exclusive ในไดเรกทอรีเดียวกัน ไฟล์ถูก flush แล้วปิด และเพิ่งจากนั้นจึงถูก rename ทับเป้าหมายด้วย MoveFileExW บน Windows หรือ rename(2) บน POSIX ถ้าอะไรล้มเหลวก่อนถึงขั้น rename ปลายทางยังมีทุกไบต์ที่มันมี และ caller เห็น LastErrorCode 305 การซ่อมเอกสารในหน่วยความจำเป็นครึ่งที่ง่ายของฟีเจอร์ซ่อม การพาผลลัพธ์ลงดิสก์โดยไม่ทิ้งให้ผู้ใช้เจอไฟล์ความยาวศูนย์หรือเขียนค้างครึ่งทางคืออีกครึ่งที่บทความนี้พูดถึง

ทำไมการซ่อมที่ล้มเหลวยังทำลายไฟล์เป้าหมายได้

เพราะลำดับการทำงานผิด ก่อน v3.539.13 RepairQDFFile เปิด output ด้วย PLCreateFileStream(OutputFileName, fmCreate) แล้วส่ง stream นั้นให้ parser fmCreate ตัดไฟล์ให้เหลือศูนย์ตอนเปิด ดังนั้นกว่าการสแกน QDF จะตัดสินว่า input ซ่อมไม่ได้ ปลายทางก็ว่างเปล่าไปแล้ว การซ่อมแบบ in-place ที่ InputFileName กับ OutputFileName เป็น path เดียวกันจึงเปลี่ยน input ที่ถูกปฏิเสธให้กลายเป็นไฟล์ที่หายไป ตัว parser เองประพฤติดี: ฟังก์ชันระดับล่าง PDFQDFRepair ไม่แตะ stream เป้าหมายเลยเมื่อมันปฏิเสธ marker ที่กำกวม การป้องกันนั้นไม่มีความหมายเลย เพราะ API สาธารณะตัดไฟล์ทิ้งไปหนึ่ง call ก่อนหน้านั้นแล้ว

fix ใน v3.539.13 ย้ายการซ่อมเข้าไปใน TMemoryStream และเปิด output หลัง PDFQDFRepair สำเร็จเท่านั้น นั่นปิดรูเรื่อง parse ล้มเหลวและปิดแค่นั้น เฟสการเขียนยังเป็น fmCreate ตามด้วย CopyFrom ดิสก์เต็ม การแชร์ไฟล์ชนกันกลางคัน หรือ exception ระหว่างการตัดไฟล์กับ WriteBuffer ตัวสุดท้ายจึงยังทิ้งปลายทางที่เสียหายไว้ได้ การซ่อมในหน่วยความจำก่อนป้องกัน input ที่แย่ การเผยแพร่ลงดิสก์ต้องมีขอบเขตของตัวเอง และ v3.539.14 กับ v3.539.15 ก็สร้างมันขึ้นมา

แผนภาพไทม์ไลน์ว่าทำไม RepairQDFFile ใน PDF Library for Delphi หยุดทำลายเป้าหมายของตัวเอง: v3.539.12 เปิด output ด้วย PLCreateFileStream และ fmCreate ซึ่งตัดไฟล์ก่อน PDFQDFRepair จะปฏิเสธ input ได้ v3.539.13 ซ่อมลง TMemoryStream ก่อน และ v3.539.15 ส่งไบต์ให้ TPDFQDFFileWriter เพื่อเผยแพร่แบบ atomic
fix เรื่อง parse ล้มเหลวกับ fix เรื่องการเผยแพร่เป็นขอบเขตคนละอัน: การซ่อมในหน่วยความจำก่อนป้องกัน input ที่แย่ ส่วนตัวเขียนมีอยู่เพื่อให้ดิสก์เต็มหรือความล้มเหลวกลางทางการเขียนทำให้ปลายทางเสียหายไม่ได้อีก
// v3.539.12: ปลายทางถูกตัดให้เหลือศูนย์ก่อน input จะถูกตรวจสอบ
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;

การเผยแพร่แบบ atomic รับประกันอะไรจริง ๆ

TPDFQDFFileWriter.Save รับประกันว่า path ปลายทางจะเป็นไฟล์เก่าที่ครบถ้วนหรือไฟล์ใหม่ที่ครบถ้วน ไม่มีทางเป็นของผสม สำหรับความล้มเหลวทุกแบบที่ library มองเห็นเอง ตัวเขียนทำสี่ขั้นตอนซึ่งแต่ละขั้นปฏิเสธที่จะไปต่อถ้าขั้นก่อนหน้าไม่จบ ขั้นแรกมัน resolve ปลายทางด้วย GetFullPathNameW เรียกสองครั้งและ allocate buffer จากความยาวที่ได้กลับมาแทนที่จะสมมติ MAX_PATH path ยาวจึงไม่ถูกตัดเงียบ ๆ ขั้นที่สองมันสร้างไฟล์ชั่วคราวชื่อ .pdflib-qdf- บวก GUID บวก .tmp ในไดเรกทอรีปลายทาง โดยใช้ CreateFileW กับ CREATE_NEW บน Windows และ open(2) กับ O_CREAT or O_EXCL โหมด 0600 บน POSIX ทั้งสองแฟล็กทำให้การสร้างล้มเหลวถ้าชื่อมีอยู่แล้ว สองโปรเซสที่แย่ง GUID เดียวกันจึงแชร์ handle กันไม่ได้ ขั้นที่สามมันคัดลอก stream ที่ซ่อมแล้วเป็นก้อนละ 64 KiB ผ่าน WriteBuffer ซึ่ง raise เมื่อเขียนได้ไม่ครบแทนที่จะคืนจำนวนที่ไม่มีใครตรวจ แล้วเรียก FlushFileBuffers หรือ fsync(2) และปิด handle ขั้นที่สี่มัน rename

แผนภาพสี่ขั้นตอนแบบ atomic ของ TPDFQDFFileWriter.Save ใน PDF Library for Delphi: resolve path สองครั้งด้วย GetFullPathNameW, สร้างไฟล์ชั่วคราว .pdflib-qdf ด้วย CREATE_NEW หรือ O_EXCL เพื่อไม่ให้โปรเซสที่แย่งกันแชร์ handle, คัดลอกเป็นก้อนละ 64 KiB ผ่าน WriteBuffer แล้ว flush จากนั้น MoveFileExW กับ REPLACE_EXISTING และ WRITE_THROUGH
แต่ละขั้นปฏิเสธที่จะไปต่อถ้าขั้นก่อนหน้าไม่จบ ไฟล์ชั่วคราวอยู่บนโวลุ่มปลายทางโดยโครงสร้าง ไม่มีช่วงที่ลบก่อนแล้วยังไม่ rename และการเก็บกวาดใน 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;

ขั้น rename คือที่ที่ routine safe save ที่เขียนเองส่วนใหญ่พังเงียบ ๆ MoveFileExW กับ MOVEFILE_REPLACE_EXISTING แทนที่เป้าหมายในการทำงานของ filesystem ครั้งเดียวบนโวลุ่มเดียวกัน ตัวเขียนจงใจไม่ใส่ MOVEFILE_COPY_ALLOWED เพราะการย้ายข้ามโวลุ่มจะกลายเป็นการคัดลอกแล้วลบ ซึ่งเป็นลำดับที่ไม่ atomic ตรง ๆ ที่การออกแบบทั้งหมดมีไว้เพื่อเลี่ยง เพราะไฟล์ชั่วคราวอยู่ในไดเรกทอรีปลายทาง มันจึงอยู่บนโวลุ่มปลายทางโดยโครงสร้าง ตัวเขียนยังไม่ลบไฟล์เก่าก่อนเลย คู่ลบแล้ว rename มีช่วงที่ path ไม่มีอยู่จริง และ crash ในช่วงนั้นทำให้เอกสารหาย MOVEFILE_WRITE_THROUGH ขอให้ call ไม่คืนค่าจนกว่า rename ไปถึงดิสก์ ซึ่งเข้าคู่กับการ flush ข้อมูลที่ทำไว้ชัดเจน บน POSIX rename(2) รับประกันอยู่แล้วว่าชื่อใหม่แทนที่ไฟล์ที่มีอยู่อย่าง atomic และการวางไว้ไดเรกทอรีเดียวกันก็กันไม่ให้มันล้มด้วย EXDEV การเก็บกวาดสมมาตรกัน ชื่อชั่วคราวถูกลบในบล็อก finally ทุกเส้นทาง ซึ่งเมื่อสำเร็จก็เป็น no-op เพราะ rename กินมันไปแล้ว และเมื่อล้มเหลวก็ลบไฟล์ที่ไม่สมบูรณ์ทิ้ง ไดเรกทอรีจึงไม่สะสมเศษ .tmp regression ใน Tests\QDFFileRegression.inc ตรวจแบบนั้นเป๊ะ: หลังความล้มเหลวที่ฉีดเข้าไปทุกแบบ ไบต์ของปลายทางตรงกับต้นฉบับ ไบต์ของต้นทางตรงกับต้นฉบับ และในไดเรกทอรีมีแค่ fixture สองไฟล์

ทำไมไฟล์ชั่วคราวถึงทำให้สิทธิ์หลวมลงบน Windows

ไฟล์ที่สร้างด้วย security descriptor เป็น nil จะสืบทอด DACL จากไดเรกทอรีแม่ ไม่ใช่จากไฟล์ที่มันกำลังจะแทนที่ นั่นเป็นค่าดีฟอลต์ที่ถูกสำหรับเอกสารใหม่เอี่ยมและผิดสำหรับการซ่อมแบบ in-place สมมติผู้ดูแลล็อก contract.pdf ให้เข้าถึงได้แค่บัญชีเดียวด้วย DACL แบบ protected ที่ไม่สืบทอด ไฟล์ชั่วคราวข้าง ๆ จะสืบทอดสิทธิ์ที่กว้างกว่าของไดเรกทอรี และเมื่อมันถูก rename ทับ contract.pdf ไฟล์ที่ถูก rename จะพา DACL กว้างนั้นไปด้วย เพราะความปลอดภัยของ NTFS ไปกับ file object ไม่ใช่ไปกับชื่อ การซ่อมสำเร็จ ไบต์ถูกต้อง และ access control ที่ผู้ดูแลตั้งไว้ก็หายไปเงียบ ๆ ไม่มีอะไรในค่าที่คืนมาบอกใบ้เรื่องนี้เลย

PDF Library for Delphi จึงอ่าน DACL ของปลายทางก่อนสร้างไฟล์ชั่วคราวและส่งมันเป็น argument lpSecurityAttributes ให้ CreateFileW ไฟล์ใหม่จึงเกิดมาพร้อมสิทธิ์ของไฟล์เก่าและการ rename ไม่เปลี่ยนอะไรที่ผู้ดูแลจะสังเกตได้ การอ่านใช้ GetFileSecurityW กับ DACL_SECURITY_INFORMATION ปรับขนาด buffer จากผล ERROR_INSUFFICIENT_BUFFER ของการเรียกครั้งแรก มีสามเงื่อนไขที่ทำให้ตัวเขียน fail closed แทนที่จะเดา ถ้าอ่าน DACL ไม่ได้ การเผยแพร่หยุดด้วย EWriteError ซึ่ง API สาธารณะ map เป็น 305 ถ้า descriptor กลับมาโดยไม่มี SE_DACL_PRESENT ถูกตั้ง การเผยแพร่ก็หยุดด้วย เพราะการส่ง descriptor แบบนั้นให้ CreateFileW จะปล่อยให้ kernel ถอยไปใช้ DACL ดีฟอลต์ของโปรเซสและเปลี่ยน semantics ของการเข้าถึงโดยไม่มีใครขอ และถ้าเป้าหมายมี FILE_ATTRIBUTE_ENCRYPTED ตัวเขียนปฏิเสธตรง ๆ เพราะไฟล์ชั่วคราวจะเป็น plaintext และการ rename ไฟล์ plaintext ทับไฟล์ที่ป้องกันด้วย EFS ก็เผยแพร่สิ่งที่ผู้ใช้เลือกจะเข้ารหัสระดับ filesystem ให้กลายเป็นของที่ไม่เข้ารหัส EFS ไม่เกี่ยวกับ security handler มาตรฐานของ PDF ซึ่งเป็นหัวข้อของบทความเรื่องการโหลดเอกสารที่เข้ารหัส แต่รูปแบบความล้มเหลวเป็น downgrade เงียบ ๆ ชนิดเดียวกัน

แผนภาพว่าทำไมตัวเขียนสำหรับเผยแพร่ QDF ถึงคัดลอก DACL ของปลายทางก่อนสร้างไฟล์ชั่วคราว: descriptor แบบ nil จะสืบทอดสิทธิ์ที่กว้างกว่าของไดเรกทอรีและการ rename จะขยายการเข้าถึงเงียบ ๆ จึงมี GetFileSecurityW อ่าน DACL และบิต SE_DACL_PRESENT ที่หายไปหรือ attribute EFS หยุดการเผยแพร่ด้วยรหัส 305 ขณะที่ CreateFileW เกิดมาพร้อมสิทธิ์เดิม
ความปลอดภัยของ NTFS ไปกับ file object ไม่ใช่ไปกับชื่อ: การส่ง descriptor ที่อ่านมาเป็น lpSecurityAttributes ทำให้ rename ไม่เปลี่ยนอะไรที่ผู้ดูแลตั้งไว้ และทุกเกต fail closed แทนที่จะเดา
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');
  // ปรับขนาด descriptor แล้วอ่านเฉพาะส่วน 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;

รายละเอียดหนึ่งจาก regression ควรค่าแก่การจำถ้าคุณเขียน test แบบเดียวกันเอง ในการสร้าง fixture ที่จำกัดสิทธิ์ test ใส่ DACL แบบเจ้าของเท่านั้นและต้องตั้ง SE_DACL_PROTECTED ใน control ของ descriptor อย่างชัดเจน การส่งธง protected ใน argument SecurityInformation ของ SetFileSecurityW เฉย ๆ ไม่ได้เปลี่ยน descriptor ที่ไม่ protected ให้เป็น protected assertion หลังจากนั้นคือไฟล์ที่เผยแพร่ยังรายงานบิต protected กับ DACL ที่ระบุชัดและไม่เป็น null ทั้งกรณี output คนละ path และกรณีซ่อมทับไฟล์ต้นทางเอง

LastErrorCode ตัวไหนบอกว่าอะไรล้มเหลว

RepairQDFFile คืน 1 เมื่อสำเร็จและ 0 เมื่อล้มเหลวไม่ว่าแบบใด และ LastErrorCode บอกว่าขั้นตอนไหนปฏิเสธ source ที่อ่านไม่ได้ รวมถึงไฟล์ที่โปรเซสอื่นถือด้วย exclusive lock รายงาน 401 การอ่านตอนนี้ถูกห่อไว้เพื่อให้ exception ระหว่าง input map เป็น 401 แทนที่จะรั่วไปเป็น write error โครงสร้าง QDF ที่ผิดหรือกำกวม เช่น stream marker ซ้ำสำหรับ object เดียวกัน รายงาน PDFLIB_ERROR_QDF_REPAIR ซึ่งก็คือ 107 และปลายทางยังไม่ถูกแตะเพราะตัวเขียนยังไม่ถูกสร้าง ทุกอย่างหลังการซ่อม ตั้งแต่สร้างไฟล์ชั่วคราวจนถึง flush และ rename รายงาน PDFLIB_ERROR_QDF_WRITE ซึ่งก็คือ 305 regression ไล่เคสที่เจอได้จริง: ปลายทางที่ถูกเปิดโดย handle อื่นแบบไม่แชร์การลบ, ปลายทางที่เขียนไม่ได้, ไดเรกทอรีปลายทางที่ไม่มี และขั้นตอนทั้งสามของตัวเขียนที่ล้มเหลวจากการฉีด ทุกเคสคืน 0 รหัสเป็น 305 และไม่มีเป้าหมายใหม่หรือเป้าหมายบางส่วนเหลืออยู่หลังจากนั้น นิสัยทั่วไปของการอ่านรหัสไม่ใช่แค่มูลค่าที่คืนมาเป็นนิสัยเดียวกับที่อธิบายในบทความเรื่องการวินิจฉัยความล้มเหลวเงียบใน library

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    // การซ่อมแบบ in-place: path เดียวกันเป็นทั้ง input และ output
    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;

หลักประกันหยุดตรงไหน

ตัวเขียนสัญญาว่าจะสอดคล้องกับความล้มเหลวที่โปรเซสมองเห็น และซื่อสัตย์กับสิ่งที่มองไม่เห็น ถ้าโปรเซสถูกฆ่าระหว่างสร้างไฟล์ชั่วคราวกับ rename บล็อก finally จะไม่ถูกรันและเหลือไฟล์ .pdflib-qdf-<GUID>.tmp ไว้ในไดเรกทอรี ปลายทางยังสมบูรณ์ ซึ่งเป็นคุณสมบัติที่สำคัญ แต่เศษที่เหลือเป็นหน้าที่คุณเก็บ ไฟดับก็อยู่นอกคำสัญญาเช่นกัน: ข้อมูลถูก flush และ rename เป็น write-through ซึ่งเป็นสิ่งที่ดีที่สุดที่ library ระดับ user mode จะขอได้ แต่ตัวเขียนไม่ได้ fsync entry ของไดเรกทอรีและไม่อ้างความทนทานเกินกว่าที่ filesystem ให้ ตัวเขียนตัวที่สองที่แก้ปลายทางพร้อมกันไม่ถูกตรวจจับ เพราะ DACL กับ attributes ถูกอ่านก่อนสร้างไฟล์ชั่วคราวและไม่มีอะไรตรวจซ้ำตอน rename และการ rename ที่สำเร็จสร้าง identity ของไฟล์ใหม่ ทำให้ alternate data stream กับ attribute ธรรมดาอย่างบิต archive หรือ hidden บนไฟล์เก่าไม่รอด มีแค่ DACL ที่ถูกพาข้ามมาอย่างตั้งใจ

ขอบเขตที่แคบกว่าคือ API ตัวไหนที่ใช้เส้นทางนี้ มีแค่ RepairQDFFile ที่ผ่าน TPDFQDFFileWriter SaveQDFToFile กับ ConvertFileToQDF ยังเปิด output ด้วย PLCreateFileStream(FileName, fmCreate) และสตรีมการแปลง QDF ลงไปตรง ๆ เหมือนกับที่เส้นทาง incremental ในบทความเรื่องการต่อท้าย update ลง stream เขียนลง stream อะไรก็ตามที่คุณส่งให้ สอง call นั้นผลิตชิ้นงานดีบักใหม่จากเอกสารที่โหลดและตรวจแล้ว รูเรื่อง parse ล้มเหลวจึงไม่เคยใช้กับมัน แต่ก็ไม่ได้สืบทอดการเผยแพร่แบบ rename มาด้วย อย่าอ่านบทความนี้ว่า การ export QDF ทุกครั้งเป็น atomic มันเป็นทางออกเดียว ทางออกที่ input เป็นไฟล์ที่แก้ด้วยมือและไม่น่าไว้ใจ และ output มักเป็น path เดียวกัน และการรวมกันนั้นเองที่ทำให้มันได้เครื่องจักรเพิ่ม ตัวฉีดความล้มเหลวที่พิสูจน์ทั้งหมดนี้ราคาถูกเพราะสามขั้นตอนของตัวเขียนอย่าง WriteData, Flush และ Publish เป็น virtual test subclass override หนึ่งในนั้นให้ raise หลังจากงานจริงเริ่มแล้ว เรียก Save บน stream ที่ซ่อมแล้ว และ assert ว่า exception ส่งต่อออกมา ไบต์ของต้นทางกับปลายทางไม่เปลี่ยน และไม่มีไฟล์ชั่วคราวเหลืออยู่ ไม่มี API ไฟล์ระดับ global ถูก hook ไม่มีไฟล์ผู้ใช้จริงถูกแตะ และสามขั้นตอน map หนึ่งต่อหนึ่งกับสามวิธีที่การเผยแพร่ล้มเหลวได้ใน production: ดิสก์เต็ม, flush ถูกปฏิเสธ หรือ rename ถูกปฏิเสธเพราะมีคนอื่นถือเป้าหมายอยู่

API RepairQDFFile, ตัวเขียนสำหรับเผยแพร่แบบ atomic และ workflow ดีบัก QDF ที่เหลือเป็นส่วนหนึ่งของ PDF Library for Delphi เคียงข้างฟีเจอร์การกู้ cross-reference, incremental update และการเข้ารหัสที่เขียนถึงที่อื่นในบล็อกนี้