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

Chunked zlib บน FPC: ทำไม FlateDecode ต้องการ stream เดียว

ก่อน v3.539.24 บิลด์ Free Pascal ของ PDF Library for Delphi บีบอัด stream ขนาดใหญ่ทีละชิ้นละ 64 KB และให้ทุกชิ้นมี zlib header กับ checksum ของตัวเอง ไฟล์แนบ 1 MiB หนึ่งไฟล์จึงกลายเป็น zlib member สิบหกตัวต่อกันเรื่อย ๆ ISO 32000-1 FlateDecode คาดหวัง zlib stream แบบเดียวเท่านั้น และ decoder มาตรฐานหยุดที่ end-of-stream marker ตัวแรก ซึ่งหมายความว่าทุกอย่างหลัง 65,536 ไบต์แรกหายไปเงียบ ๆ การแก้คือรักษา zlib state หนึ่งตัวให้มีชีวิตข้ามทุกชิ้นทั้งใน DeflateStream และ InflateStream

bug นี้มีอยู่แค่ฝั่ง FPC ของ unit เดียว และอยู่แค่เส้นทาง streaming แบบ chunked ซึ่งเป็นเหตุผลที่มันรอดมาได้พอดี: สาขา Delphi ถูกมาตลอด helper ที่ทำงานบน string ก็ถูกมาตลอด และ payload ทดสอบขนาดเล็กไม่เคยแตะโค้ด chunked เลย มันเป็นญาติสนิทของข้อผิดพลาดในbug พอร์ต FPC ห้าตัวที่ Delphi ปิดบังไว้เงียบ ๆ ต่างกันที่ที่นี่บิลด์ Delphi ไม่ได้ช่วยปิดบังอะไรเลย สาขา FPC แค่ถูกเขียนขึ้นจากภาพจำที่ผิดว่า zlib stream คืออะไร

ทำไม zlib stream แบบ chunked ถึงถูกตัดสั้นโดย PDF reader ตัวอื่น

เพราะ zlib stream ตามนิยามของ RFC 1950 เป็น container เดียว ไม่ใช่ลำดับของหลายตัว และ inflater ที่ทำตามสเปกถือว่า Adler-32 trailer ตัวแรกคือจุดจบของข้อมูล รูปแบบคือ header สองไบต์ ต่อด้วย deflate bit stream ต่อเนื่องหนึ่งสายที่บล็อกสุดท้ายติดธง final-block และจบด้วย checksum Adler-32 สี่ไบต์ครอบไบต์ที่คลายแล้วทั้งหมด ISO 32000-1 §7.4.4 นิยาม /FlateDecode ในแง่นี้พอดี เมื่อ inflate ไปถึง trailer มันคืน Z_STREAM_END แล้วทิ้ง input ที่เหลือไว้ใน avail_in โดยไม่อ่าน จากมุมมองของมันไม่มีอะไรผิด มันจึงไม่โยน error และไบต์หลัง trailer ก็ถูกเมินไปเฉย ๆ การต่อ member เข้าด้วยกันเป็นแนวคิดที่ชอบธรรมใน gzip (RFC 1952 ยอมให้หลาย member อยู่ในไฟล์เดียว) ซึ่งน่าจะเป็นที่มาของสัญชาตญาณนี้ แต่ zlib ไม่มีกฎแบบนั้น และ PDF ก็ไม่เคยขอด้วย

กายวิภาคของ zlib member ตาม RFC 1950 ใน FlateDecode stream ของ PDFlibPas: header สองไบต์, deflate bit stream ต่อเนื่องหนึ่งสายที่บล็อกสุดท้ายติดธง final-block และ Adler-32 trailer สี่ไบต์ที่ inflate คืน Z_STREAM_END โดยทิ้ง input ที่เหลือไว้ใน avail_in แบบไม่โยน error
inflater ที่ทำตามสเปกถือว่า Adler-32 trailer ตัวแรกคือจุดจบของข้อมูล เขตแดนของ member จึงเป็นกำแพงหยุดเด็ดขาด และทุกอย่างที่ writer เคลียบต่อท้ายมาคือของตายที่ไม่มี reader ตัวไหนจะไป decode

DeflateStream เดิมของฝั่ง FPC อ่านต้นทางทีละ 64 KB แล้วส่งแต่ละชิ้นให้ ZFPCCompress ซึ่งเป็น helper ที่รัน deflateInit2 ของตัวเอง บีบอัดด้วย Z_FINISH แล้วเรียก deflateEnd ทุกชิ้นจึงออกมาเป็น zlib stream ที่สมบูรณ์ ถูกต้อง และปิดตัวเองได้ แล้วฟังก์ชันก็เอามาต่อกันเป็น AnsiString เดียวก่อนเขียนออก ผลลัพธ์หน้าตาเหมือนข้อมูล Flate มี header ถูกต้อง decode ได้โดยไม่โยน error แต่คลายออกมาได้แค่ 65,536 ไบต์แรกเท่านั้น ฝั่งอ่าน InflateStream มีความผิดพลาดแบบสะท้อนกลับ: มันเรียก ZFPCInflate หนึ่งครั้งต่อ input ที่บีบอัดแล้ว 64 KB และแต่ละครั้งเริ่ม inflateInit2 ใหม่เอี่ยม ชิ้นที่สองเริ่มกลาง deflate bit stream โดยไม่มี zlib header ดังนั้น inflater ตัวใหม่จึงปฏิเสธมัน และ stream single-member ที่ปกติดีมาจาก producer อื่นก็ถูก decode ได้เท่าที่ไบต์บีบอัด 64 KB แรกพาไปถึง

ข้อผิดพลาดของ writer แบบ chunked ใน DeflateStream ฝั่ง FPC ของ PDFlibPas: ทุกชิ้น 64 KB ผ่าน ZFPCCompress เป็น zlib member ที่สมบูรณ์ ไฟล์แนบ 1 MiB จึงมี member ต่อกันสิบหกตัว reader หยุดที่ trailer แรกหลัง 65,536 ไบต์ และ GetEmbeddedFileContentToStream ยังรายงานสำเร็จอยู่
ความเสียหายนี้มองไม่เห็นเพราะทุกชั้นล้วนสำเร็จ: dictionary ของไฟล์แนบประกาศขนาดเต็มใน /Params /Size, การ decode ไม่โยน error และมีแค่คนที่เปิดไฟล์แนบเท่านั้นที่จะเจอว่าข้อมูลถูกตัด
// DeflateStream ฝั่ง FPC ก่อน v3.539.24 (ย่อ):
// ZFPCCompress ทำ deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// ทุกชิ้น 64 KB จึงกลายเป็น zlib member แยกต่างหาก
Repeat
  ReadCount:= Source.Read(Input[1], ChunkSize);
  If (ReadCount> 0) Then
    Compressed:= Compressed+ ZFPCCompress(Copy(Input, 1, ReadCount), 6, False);
Until (ReadCount< ChunkSize);
If (Compressed<> '') Then
  Target.WriteBuffer(PAnsiChar(Compressed)^, Length(Compressed));

call ไหนของ PDF Library for Delphi บ้างที่แตะเส้นทาง chunked

บน FPC embedded file ที่ตั้งแต่ 1 MiB ขึ้นไปถูกเขียนผิดทั้งหมด และ Flate stream ที่ดึงออกผ่าน streaming API ถูกอ่านผิดทันทีที่ขนาดหลังบีบอัดข้ามหนึ่งชิ้น TPDFStream.ReadFromStream เป็นตัวตัดสินวิธีเข้ารหัสข้อมูลที่เข้ามา เมื่อ Deflate เป็น true และ Stream.Size >= 1048576 มันจะ stream ผ่าน DeflateStream ถ้าต่ำกว่าเกณฑ์นี้จะอ่านต้นทางทั้งหมดเข้าหน่วยความจำแล้วเรียก DeflateStr helper แบบครั้งเดียวจบที่ไม่เคยได้รับผลกระทบ ส่วน filter chain ASCII85 บวก Flate ข้ามการทดสอบขนาดและผ่าน DeflateStream เสมอ เส้นทางนั้น payload อะไรที่ใหญ่กว่า 64 KB จึงถูกแยกเป็นหลาย member ไปแล้ว จุดเข้าสาธารณะที่ป้อน ReadFromStream โดยเปิดการบีบอัดอยู่คือกลุ่ม writer ของ embedded file:

  • TPDFlib.EmbedFile กับ TPDFlib.AddEmbeddedFile ที่อ่านไฟล์จากดิสก์เข้าสู่ stream /EmbeddedFile
  • TPDFlib.AddAssociatedFileFromStream กับ TPDFlib.AddAssociatedFileFromFile กลุ่ม writer ของ associated file สำหรับ PDF/A-3 ที่ใช้กับ e-invoice XML และข้อมูลต้นทางอื่น ๆ
  • ฝั่งอ่าน TPDFlib.GetEmbeddedFileContentToStream กับ GetEmbeddedFileContentToFile ที่ decode ผ่าน TPDFStream.WriteDecodedToStream แล้วต่อด้วย InflateStream

ความล้มเหลวนี้เงียบในทุกชั้น writer เก็บ /Params /Size กับ /CheckSum แบบ MD5 ที่คำนวณจากไฟล์ต้นฉบับ ดังนั้น dictionary ของไฟล์แนบจึงประกาศขนาดเต็มขณะที่ใน stream มี member อยู่สิบหกตัว บิลด์ Delphi ของ library เดียวกันอ่านไฟล์นั้นแล้วหยุดเรียบร้อยที่ Z_STREAM_END ตัวแรกและคืนมาพอดี 65,536 ไบต์ GetEmbeddedFileContentToStream คืน 1 เพราะมันรายงานว่าการ decode โยน error หรือไม่ ไม่ได้รายงานว่าผลลัพธ์ตรงกับ /Size ไหม ใครที่เคยไล่ปัญหาเอกสารใหญ่ผ่านการ merge และ split PDF ขนาดกิกะไบต์จะรู้จักรูปแบบนี้ดี: ไฟล์เปิดได้ จำนวนหน้าถูกต้อง และความเสียหายโผล่ตอนที่มีคนเปิดไฟล์แนบเท่านั้น

deflate state เดียวข้ามทุกชิ้น

DeflateStream ที่แก้แล้วใน PDFlibZLib.pas initialize paszlib.TZStream หนึ่งตัว ป้อนทุกชิ้นให้ deflate ด้วย Z_NO_FLUSH แล้วจึงค่อยระบาย compressor ด้วย Z_FINISH ตอนจบจนกว่าจะคืน Z_STREAM_END ผลที่ได้คือ header หนึ่งตัวพอดี deflate bit stream หนึ่งสายที่ back-reference เอื้อมข้ามเขตแดนชิ้นได้ และ Adler-32 หนึ่งตัวครอบ input ทั้งหมด สาขา FPC ตอนนี้จึงมีโครงสร้างแบบเดียวกับที่สาขา Delphi เป็นมาตลอด และมันยังเขียน output ออกไประหว่างทำงานด้วย แทนที่จะเอาผลบีบอัดทั้งก้อนไปต่อรวมใน AnsiString ก่อน writer จึงไม่ต้องสร้างสำเนาเต็มที่สองของข้อมูลบีบอัดไว้ในหน่วยความจำอีกต่อไปก่อนคัดลอกไปที่ปลายทาง

DeflateStream ที่แก้แล้วใน PDFlibPas: paszlib.TZStream หนึ่งตัว initialize ครั้งเดียว ป้อนทุกชิ้น 64 KB ด้วย Z_NO_FLUSH และระบายจบด้วย Z_FINISH หนึ่งครั้ง ได้ header หนึ่งตัวพอดี deflate bit stream ต่อเนื่องหนึ่งสายที่ back-reference ข้ามเขตแดนชิ้นได้ และ Adler-32 หนึ่งตัวครอบ input ทั้งหมด
การมี state เดียวยังเปลี่ยนวิธีเขียน output: ไบต์บีบอัดออกไปทันทีที่ buffer เต็มแทนที่จะกองไว้ในสำเนาเต็มที่สอง และ PLDeflateLevel ตอนนี้ถึง embedded file ขนาดใหญ่บน FPC ด้วย
// DeflateStream ฝั่ง FPC ตั้งแต่ v3.539.24 (ตัดเส้นทาง error ออก)
If (deflateInit2(strm, Level, Z_DEFLATED, 15, 8, Z_DEFAULT_STRATEGY)<> Z_OK) Then
  Exit;
Try
  Repeat
    ReadCount:= Source.Read(Input[0], ChunkSize);
    If (ReadCount> 0) Then
    Begin
      strm.next_in:= Pointer(Input);
      strm.avail_in:= ReadCount;
      While (strm.avail_in> 0) Do
      Begin
        strm.next_out:= Pointer(Output);
        strm.avail_out:= ChunkSize;
        Status:= deflate(strm, Z_NO_FLUSH);   // state เดิม ไม่มีการเบรก member
        Produced:= ChunkSize- strm.avail_out;
        If (Produced> 0) Then
          Target.WriteBuffer(Output[0], Produced);
        If (Status<> Z_OK) Then
          Break;
      End;
    End;
  Until (ReadCount< ChunkSize);
  Repeat                                    // trailer เดียวสำหรับ input ทั้งหมด
    strm.next_out:= Pointer(Output);
    strm.avail_out:= ChunkSize;
    Status:= deflate(strm, Z_FINISH);
    Produced:= ChunkSize- strm.avail_out;
    If (Produced> 0) Then
      Target.WriteBuffer(Output[0], Produced);
  Until (Status= Z_STREAM_END);
Finally
  deflateEnd(strm);
End;

InflateStream ถูกเขียนใหม่แบบสมมาตร: inflateInit2 หนึ่งครั้ง loop ข้างในคอยเรียก inflate จนกว่าชิ้นปัจจุบันถูกกินหมดและ output buffer ไม่เต็มอีก แล้วหยุดที่ Z_STREAM_END มี side effect หนึ่งอย่างที่ควรรู้ writer แบบ chunked เดิม hardcode level ไว้ที่ 6 ส่วนตัวใหม่เคารพ PLDeflateLevel ดังนั้น level ที่ตั้งผ่าน TPDFlib.SetCompressionLevel(1..9) ตอนนี้มีผลกับ embedded file ขนาดใหญ่บน FPC ด้วย เรื่องนี้สำคัญถ้าคุณ tune การบีบอัดไว้สำหรับ output เชิง archive อยู่แล้วตามที่พูดถึงในการลดขนาดไฟล์ PDF ใน Delphi

จะตรวจยังไงว่า Flate stream เป็น zlib member เดียว

คลายมันด้วย zlib decoder ธรรมดาแล้วเช็กสองอย่างตอนที่มันคืน Z_STREAM_END: ความยาวที่คลายได้เท่ากับความยาวต้นทาง และ avail_in เป็นศูนย์ input ที่เหลือค้างหลัง end marker คือลายเซ็นของ stream ที่ถูกต่อกัน การแก้ได้รับการยืนยันด้วยวิธีนี้: ข้อมูลทดสอบ 200 KB ซึ่งกินสี่ชิ้นแบบ 64 KB ผ่าน DeflateStream ใหม่แล้วออกมาเป็น zlib stream เดี่ยวขนาด 534 ไบต์, zlib decoder สำเร็จรูปกู้คืนได้ครบ 200,000 ไบต์โดยไม่เหลือ input และการเช็กเดียวกันผ่านบน target FPC ที่ cross-compile เป็น i386 routine ด้านล่างคือเวอร์ชัน FPC ของการเช็กนี้ สร้างบน paszlib โดยตรงจึงไม่ไว้ใจโค้ดที่กำลังทดสอบ

uses Classes, SysUtils, paszlib, PDFlibZLib;

function IsSingleZlibMember(Packed: TMemoryStream; out Decoded: Int64): Boolean;
var
  strm: TZStream;
  Buf: array[0..65535] of Byte;
  Status: Integer;
begin
  Result:= False;
  Decoded:= 0;
  FillChar(strm, SizeOf(strm), 0);
  if inflateInit2(strm, 15) <> Z_OK then
    Exit;
  try
    strm.next_in:= Packed.Memory;
    strm.avail_in:= Cardinal(Packed.Size);
    repeat
      strm.next_out:= @Buf;
      strm.avail_out:= SizeOf(Buf);
      Status:= inflate(strm, Z_NO_FLUSH);
    until Status <> Z_OK;
    Decoded:= strm.total_out;
    // member เดียวต้องจบพอดีที่ไบต์ input สุดท้าย
    Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
  finally
    inflateEnd(strm);
  end;
end;

// ดันไบต์ 200,000 ตัวผ่าน DeflateStream ด้วยชิ้น default 64 KB
// แล้วบังคับว่ามี member เดียวที่คลายกลับมาได้ครบความยาว
Source.Position:= 0;
DeflateStream(Source, Packed);
if not (IsSingleZlibMember(Packed, Decoded) and (Decoded = Source.Size)) then
  raise Exception.Create('DeflateStream produced more than one zlib member');

ระดับแอปพลิเคชัน assertion ที่ใช้ประโยชน์ได้จริงคืออันที่ library ไม่ได้ทำให้คุณ: เทียบสิ่งที่ดึงออกจากไฟล์แนบกับ /Params /Size ที่บันทึกไว้ตอนเอาเข้า GetEmbeddedFileIntProperty ด้วย tag 5 คืนขนาดที่บันทึกไว้นั้น index ของ embedded file เริ่มที่ 1 และ payload ต้องใหญ่อย่างน้อย 1 MiB เสียก่อนถึงจะแตะเส้นทาง streaming รัน test เดียวกันบน compiler ทุกตัวที่คุณส่งมอบไปกับตัวผลิตภัณฑ์ เพราะข้อผิดพลาดเดิมผ่านบน Delphi และพังแค่บน FPC

procedure CheckLargeAttachmentRoundTrip(const PayloadFile, OutFile: string);
var
  PDF: TPDFlib;
  Extracted: TMemoryStream;
  I, Declared: Integer;
begin
  PDF:= TPDFlib.Create;
  try
    PDF.NewDocument;
    PDF.AddStandardFont(4);
    PDF.DrawText(80, 100, 'Large attachment round trip');
    // ตั้งแต่ 1 MiB ขึ้นไปเข้าเส้นทาง DeflateStream แบบ chunked ใน ReadFromStream
    if PDF.EmbedFile('Payload', PayloadFile, 'application/octet-stream') <> 1 then
      raise Exception.Create('EmbedFile failed');
    if PDF.SaveToFile(OutFile) <> 1 then
      raise Exception.Create('SaveToFile failed');
  finally
    PDF.Free;
  end;

  PDF:= TPDFlib.Create;
  Extracted:= TMemoryStream.Create;
  try
    if PDF.LoadFromFile(OutFile, '') = 0 then
      raise Exception.Create('LoadFromFile failed');
    for I:= 1 to PDF.EmbeddedFileCount do
    begin
      Extracted.Clear;
      if PDF.GetEmbeddedFileContentToStream(I, Extracted) <> 1 then
        raise Exception.CreateFmt('Attachment %d could not be decoded', [I]);
      Declared:= PDF.GetEmbeddedFileIntProperty(I, 5);   // /Params /Size
      if Extracted.Size <> Declared then
        raise Exception.CreateFmt('Attachment %d truncated: %d of %d bytes',
          [I, Extracted.Size, Declared]);
    end;
  finally
    Extracted.Free;
    PDF.Free;
  end;
end;

อะไรบ้างที่การแก้นี้ไม่ได้เปลี่ยน

สาขา FPC ยังคงกฎการ decode แบบเชื่องของตัวเอง และไม่ได้ซ่อมไฟล์ที่บิลด์ FPC รุ่นก่อนเขียนไว้แล้ว เมื่อถึง MaxOutput InflateStream ฝั่ง FPC จะตัดที่เพดานแล้วคืนค่า ขณะที่ฝั่ง Delphi โยน ERangeError และฝั่ง FPC ยังยอมรับ output ที่คลายมาเพียงบางส่วนเมื่อ inflate รายงาน data error เพราะ producer บางตัวปล่อย stream ที่ถูกตัดหรือ checksum พังออกมา PDF ที่เขียนโดยบิลด์ FPC ก่อน v3.539.24 ยังมี member ต่อกันอยู่ข้างใน และ reader ที่แก้แล้วก็เหมือน reader ทุกตัว คือหยุดที่ Z_STREAM_END ตัวแรก อย่าพยายามเยียวยาไฟล์แบบนั้นด้วยการ decode แล้ว encode ใหม่ภายใน library เพราะมันแค่เปลี่ยนการตัด 64 KB ให้ถาวรขึ้นเท่านั้น ให้เอาไฟล์แนบจากแหล่งต้นฉบับมา embed ใหม่แทน loop ฝั่ง FPC ยังจบที่การอ่านแรกที่ได้มาไม่เต็มชิ้นด้วย ซึ่งเป็นสัญญาณจบข้อมูลเฉพาะกับ stream อย่าง TFileStream กับ TMemoryStream เท่านั้น TStream ที่เขียนเองส่งเข้า AddAssociatedFileFromStream จึงปลอดภัยสุดถ้า copy ลง TMemoryStream ก่อน สาขา Delphi, helper DeflateStr กับ InflateStr และทุก stream ที่เล็กกว่า 1 MiB บนเส้นทาง Flate ธรรมดาทำงานเหมือนเดิมทุกอย่าง

DeflateStream และ InflateStream ฝั่ง FPC ที่แก้แล้ว ship มากับ v3.539.24 ของPDF Library for Delphi ที่รองรับทั้ง Delphi, C++Builder และ Free Pascal จาก source tree เดียว และไฟล์แนบขนาดใหญ่ควรกลับมาจากบิลด์ FPC ได้ไบต์ต่อไบต์ อย่างเดียวกับที่เคยเป็นจาก Delphi มาตลอด