מאמר טכני

zlib מחולק ב-FPC: למה FlateDecode צריך זרם אחד

לפני v3.539.24, ה-build של PDF Library for Delphi על Free Pascal דחס זרמים גדולים בחתיכות של 64 KB והעניק לכל חתיכה כותרת zlib וסכום ביקורת משל עצמה, כך שקובץ מצורף בגודל 1 MiB הפך לשישה-עשר חברי zlib שהודבקו קצה בקצה. FlateDecode של ISO 32000-1 מצפה בדיוק לזרם zlib אחד, ומפענח תקני עוצר במחוון סוף-הזרם הראשון, כלומר כל מה שבא אחרי 65,536 הבתים הראשונים נעלם בשקט. התיקון שומר על מצב zlib אחד חי לאורך כל החתיכות, גם ב-DeflateStream וגם ב-InflateStream

הבאג התקיים רק בצד ה-FPC של היחידה, ורק בנתיב ה-streaming המחולק לחתיכות, וזו בדיוק הסיבה שהוא שרד: הענף של Delphi היה תמיד נכון, ה-helpers מבוססי המחרוזת היו תמיד נכונים, ומטעני בדיקה קטנים מעולם לא הגיעו בכלל לקוד המחולק. זה קרוב משפחה של הליקויים בחמישה באגים של הסבה ל-FPC ש-Delphi הסתירה, רק שכאן ה-build של Delphi לא כיסה על אף אחד. הענף של FPC פשוט נכתב מתוך מודל מנטלי שגוי של מהו זרם zlib

למה קוראי PDF אחרים קוטעים זרם zlib שחולק לחתיכות?

כי זרם zlib כפי ש-RFC 1950 מגדיר אותו הוא מיכל אחד, לא רצף של מיכלים, ומנפח תקני מתייחס ל-trailer של ה-Adler-32 הראשון כאל סוף הנתונים. הפורמט הוא כותרת בת שני בתים, זרם סיביות deflate אחד רציף לפי RFC 1951 שהבלוק האחרון שלו נושא את דגל הבלוק הסופי, וסכום ביקורת Adler-32 בן ארבעה בתים על כל הבתים הלא דחוסים. ISO 32000-1 §7.4.4 מגדיר את /FlateDecode בדיוק במונחים האלה. כש-inflate מגיע ל-trailer הוא מחזיר Z_STREAM_END ומשאיר כל קלט שנותר בלי לקרוא ב-avail_in. מנקודת מבטו לא קרה שום דבר רע, ולכן הוא לא מעלה שגיאה, והבתים שאחרי ה-trailer פשוט מוזנחים. חברים משורשרים הם רעיון לגיטימי ב-gzip (RFC 1952 מתיר כמה חברים בקובץ אחד), וסביר שמשם באה האינטואיציה, אבל ל-zlib אין כלל כזה ו-PDF מעולם לא ביקש אחד

אנטומיה של חבר zlib לפי RFC 1950 בזרמי FlateDecode של PDFlibPas: כותרת בת שני בתים, זרם סיביות deflate רציף אחד שהבלוק האחרון שלו נושא את דגל הבלוק הסופי, ו-trailer Adler-32 בן ארבעה בתים שבו inflate מחזיר Z_STREAM_END ומשאיר קלט שנותר בלי לקרוא ב-avail_in בלי להעלות שגיאה
מנפח תקני מתייחס ל-trailer ה-Adler-32 הראשון כאל סוף הנתונים, ולכן גבול חבר הוא עצירה נוקשה וכל מה שכותב הדביק אחריו הוא משקל מת שאף קורא לא יפענח

ה-DeflateStream הישן ב-FPC קרא את המקור 64 KB בכל פעם ומסר כל חתיכה ל-ZFPCCompress, helper שמריץ deflateInit2 משל עצמו, דוחס עם Z_FINISH וקורא ל-deflateEnd. כל חתיכה יצאה אפוא כזרם zlib שלם, תקין ומסיים את עצמו, והפונקציה שרשרה את כולן ל-AnsiString אחד לפני הכתיבה. התוצאה נראתה כמו נתוני Flate, הייתה לה כותרת נכונה, והיא התפענחה בלי שגיאה — אבל התפענחה ל-65,536 הבתים הראשונים בלבד. בצד הקריאה, ל-InflateStream היה ליקוי תאום-מראה: היא קראה ל-ZFPCInflate פעם אחת לכל 64 KB של קלט דחוס, וכל קריאה פתחה inflateInit2 טרי. החתיכה השנייה מתחילה באמצעו של זרם סיביות deflate בלי כותרת zlib, ולכן מנפח חדש דוחה אותה, וזרם חבר-יחיד תקין לגמרי מכל מפיק אחר פוענח רק עד כמה ש-64 KB הבתים הדחוסים הראשונים שלו הספיקו להוביל אותו

ליקוי הכותב המחולק ב-DeflateStream של FPC ב-PDFlibPas: כל חתיכה של 64 KB עוברת דרך ZFPCCompress כחבר zlib שלם, כך שקובץ מצורף של 1 MiB מכיל שישה-עשר חברים מודבקים, קורא עוצר ב-trailer הראשון אחרי 65,536 בתים, ו-GetEmbeddedFileContentToStream עדיין מדווח על הצלחה
הנזק נשאר בלתי נראה כי כל שכבה הצליחה: מילון הקובץ המצורף הצהיר על ה-/Params /Size המלא, הפענוח לא העלה שגיאה, ורק מי שפתח בפועל את הקובץ המצורף שם לב לקיצוץ
// DeflateStream ב-FPC לפני v3.539.24 (מפושט):
// ZFPCCompress עושה deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// ולכן כל חתיכה של 64 KB הופכת לחבר zlib נפרד
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));

אילו קריאות של PDF Library for Delphi הגיעו לנתיב המחולק?

על FPC, כל קובץ מוטמע של 1 MiB ומעלה נכתב שגוי, וכל זרם Flate שחולץ דרך ה-API של ה-streaming נקרא שגוי ברגע שגודלו הדחוס עבר חתיכה אחת. TPDFStream.ReadFromStream מחליט איך לקודד נתונים נכנסים. כש-Deflate דלוק ו-Stream.Size >= 1048576 הוא מזרים דרך DeflateStream; מתחת לסף הזה הוא קורא את כל המקור לזיכרון וקורא ל-DeflateStr, ה-helper חד-הפעם שמעולם לא נפגע. שרשרת המסננים ASCII85-plus-Flate מדלגת על בדיקת הגודל ותמיד עוברת דרך DeflateStream, כך שבנתיב הזה כל מטען גדול מ-64 KB כבר פוצל לכמה חברים. נקודות הכניסה הציבוריות שמזרימות נתונים ל-ReadFromStream עם דחיסה דלוקה הן כותבי הקבצים המוטמעים:

  • TPDFlib.EmbedFile ו-TPDFlib.AddEmbeddedFile, שקוראים קובץ מהדיסק לתוך זרם /EmbeddedFile
  • TPDFlib.AddAssociatedFileFromStream ו-TPDFlib.AddAssociatedFileFromFile, כותבי הקבצים הנלווים של PDF/A-3 המשמשים ל-XML של חשבונית אלקטרונית ונתוני מקור אחרים
  • בצד הקריאה, TPDFlib.GetEmbeddedFileContentToStream ו-GetEmbeddedFileContentToFile, שמפענחים דרך TPDFStream.WriteDecodedToStream ומשם דרך InflateStream

הכשל היה שקט בכל שכבה. הכותב מאחסן /Params /Size ו-MD5 /CheckSum שחושב מהקובץ המקורי, כך שמילון הקובץ המצורף הצהיר על הגודל המלא בזמן שהזרם החזיק שישה-עשר חברים. build של Delphi של אותה ספרייה שקרא את הקובץ עצר נקי ב-Z_STREAM_END הראשון והחזיר בדיוק 65,536 בתים. GetEmbeddedFileContentToStream החזיר 1, כי הוא מדווח אם הפענוח העלה שגיאה, לא אם הפלט תואם ל-/Size. מי שרדף אחרי בעיה במסמך גדול דרך מיזוג ופיצול של PDF בני ג'יגה-בייט מכיר את התבנית הזאת: הקובץ נפתח, מספר העמודים נכון, והנזק מתגלה רק כשמישהו פותח את הקובץ המצורף

מצב deflate אחד לאורך כל החתיכות

ה-DeflateStream המתוקן ב-PDFlibZLib.pas מאתחל paszlib.TZStream אחד, מזרים כל חתיכה ל-deflate עם Z_NO_FLUSH, ורק בסוף מרוקן את המדחס עם Z_FINISH עד שהוא מחזיר Z_STREAM_END. זה מניב בדיוק כותרת אחת, זרם סיביות deflate אחד שה-back-references שלו יכולים להגיע מעבר לגבולות חתיכות, ו-Adler-32 אחד על כל הקלט. לענף ה-FPC יש עכשיו את אותו מבנה שהיה תמיד לענף ה-Delphi. הוא גם כותב פלט כפי שהוא נוצר, במקום לשרשר קודם את כל התוצאה הדחוסה ל-AnsiString אחד, כך שהכותב כבר לא בונה עותק מלא שני של הנתונים הדחוסים בזיכרון לפני שהוא מעתיק אותם ליעד

DeflateStream המתוקן ב-PDFlibPas: paszlib.TZStream אחד המאותחל פעם אחת, כל חתיכה של 64 KB מוזרמת עם Z_NO_FLUSH וניקוז Z_FINISH סופי, כך שנוצרים בדיוק כותרת אחת, זרם סיביות deflate רציף אחד שה-back-references שלו חוצים גבולות חתיכות ו-Adler-32 אחד על כל הקלט
מצב אחד משנה גם איך הפלט נכתב: הבתים הדחוסים יוצאים כשכל חוצץ מתמלא במקום להצטבר בעותק מלא שני, ו-PLDeflateLevel מגיע עכשיו גם לקבצים מוטמעים גדולים על FPC
// DeflateStream ב-FPC החל מ-v3.539.24 (נתיבי שגיאה קוצצו)
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);   // אותו מצב, בלי שבירת חבר
        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 אחד לכל הקלט
    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 אחד, לולאה פנימית שממשיכה לקרוא ל-inflate עד שהחתיכה הנוכחית נצרכה וחוצץ הפלט כבר לא מלא, ועצירה על Z_STREAM_END. יש תופעת לוואי אחת ששווה להכיר. הכותב המחולק הישן קיבע את רמה 6 בקוד, ואילו החדש מכבד את PLDeflateLevel, כך שרמה שנקבעה דרך TPDFlib.SetCompressionLevel(1..9) חלה עכשיו גם על קבצים מוטמעים גדולים על FPC. זה חשוב אם כבר כיילתם דחיסה לפלט ארכיוני כמתואר בהקטנת גודל קובץ PDF בדלפי

איך מוודאים שזרם Flate הוא חבר zlib יחיד?

מנפחים אותו עם מפענח zlib פשוט ובודקים שני דברים כשהוא מחזיר Z_STREAM_END: אורך הפלט המפוענח שווה לאורך המקור, ו-avail_in הוא אפס. קלט שנותר אחרי מחוון הסיום הוא החתימה של זרם משורשר. ככה אימתו את התיקון: 200 KB של נתוני בדיקה, שמשתרעים על פני ארבע חתיכות של 64 KB, עברו דרך ה-DeflateStream החדש ויצאו כזרם zlib יחיד בן 534 בתים, מפענח zlib מן המדף שחזר את כל 200,000 הבתים בלי קלט שנותר, ואותה בדיקה עברה גם ביעד ה-FPC שהודר עבור i386. השגרה למטה היא גרסת ה-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;
    // חבר אחד מסתיים בדיוק בבית הקלט האחרון
    Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
  finally
    inflateEnd(strm);
  end;
end;

// מעבירים 200,000 בתים דרך DeflateStream עם חתיכת ברירת המחדל של 64 KB
// ודורשים חבר אחד שמתפענח בחזרה לאורך המלא
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');

ברמת היישום, הטענה השימושית היא זאת שהספרייה לא עושה בשבילכם: משווים את מה שיוצא מקובץ מצורף אל ה-/Params /Size שנרשם כשהוא נכנס. GetEmbeddedFileIntProperty עם תג 5 מחזיר את הגודל הרשום הזה, האינדקסים של קבצים מוטמעים הם מבוססי-1, והמטען צריך להיות לפחות 1 MiB כדי להפעיל את נתיב ה-streaming. מריצים את אותה בדיקה על כל מהדר שאיתו אתם משלחים, כי הליקוי המקורי עבר על 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 המחולק ב-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 שומר על כללי הפענוח הסלחניים שלו, והוא לא מתקן קבצים ש-builds קודמים של FPC כבר כתבו. כשמגיעים ל-MaxOutput, ה-InflateStream של FPC קוטע בגבול וחוזר, בזמן שהענף של Delphi מעלה ERangeError, ו-FPC עדיין מקבל פלט שפוענח חלקית כש-inflate מדווח על שגיאת נתונים, כי יש מפיקי PDF שמשגרים זרמים קטועים או כאלה עם סכום ביקורת שבור. PDF שנכתב על ידי build FPC מלפני v3.539.24 עדיין מכיל חברים משורשרים, והקורא המתוקן, כמו כל קורא אחר, עוצר ב-Z_STREAM_END הראשון. אל תנסו לרפא קובץ כזה על ידי פענוח וקידוד מחדש של הזרם בתוך הספרייה, כי זה רק קובע את קיצוץ ה-64 KB לצמיתות. במקום זאת, מטמיעים מחדש את הקובץ המצורף מהמקור המקורי שלו. הלולאה של FPC גם עדיין מסתיימת בקריאה הראשונה שמחזירה פחות מחתיכה מלאה, וזה אות סוף-נתונים רק עבור זרמים כמו TFileStream ו-TMemoryStream, ולכן TStream מותאם אישית שמועבר ל-AddAssociatedFileFromStream הכי בטוח להעתיק קודם ל-TMemoryStream. הענף של Delphi, ה-helpers DeflateStr ו-InflateStr, וכל זרם קטן מ-1 MiB בנתיב ה-Flate הפשוט מתנהגים בדיוק כמו קודם

ה-DeflateStream וה-InflateStream המתוקנים של FPC מגיעים ב-v3.539.24 של PDF Library for Delphi, שמיועדת ל-Delphi, ל-C++Builder ול-Free Pascal מעץ מקור אחד, ושם קובץ מצורף גדול צריך עכשיו לחזור מ-build של FPC בית אחר בית, בדיוק כפי שתמיד חזר מ-Delphi