Artykuł techniczny

Chunked zlib na FPC: FlateDecode wymaga jednego strumienia

Przed wersją v3.539.24 budowa Free Pascal biblioteki PDF Library for Delphi kompresowała duże strumienie w kawałkach po 64 KB i dopisywała każdemu kawałkowi własny nagłówek oraz sumę kontrolną zlib, więc załącznik o rozmiarze 1 MiB stawał się szesnastoma członami zlib sklejonymi końcem do końca. FlateDecode z ISO 32000-1 oczekuje dokładnie jednego strumienia zlib, a standardowy dekoder zatrzymuje się na pierwszym znaczniku końca strumienia, co oznacza, że wszystko za pierwszymi 65 536 bajtami po prostu znikało. Poprawka utrzymuje jeden stan zlib przy życiu przez wszystkie kawałki, zarówno w DeflateStream, jak i w InflateStream

Błąd istniał wyłącznie po stronie FPC tej jednostki i tylko na strumieniowej ścieżce kawałkowania, i dokładnie dlatego przetrwał: gałąź Delphi zawsze działała poprawnie, helpery oparte na łańcuchach zawsze działały poprawnie, a małe testowe dane nigdy w ogóle nie dochodziły do kodu kawałkowania. To bliski kuzyn defektów opisanych w artykule o pięciu błędach portowania na FPC, które Delphi skutecznie maskowało, z tą różnicą, że tym razem budowa Delphi nikogo nie okrywała. Gałąź FPC została po prostu napisana w oparciu o błędny model tego, czym w ogóle jest strumień zlib

Dlaczego inne czytniki PDF ucinają strumień zlib podzielony na kawałki?

Bo strumień zlib w definicji RFC 1950 to jeden kontener, a nie ich sekwencja, i zgodny ze standardem inflater traktuje pierwszą stopkę Adler-32 jako koniec danych. Format to dwubajtowy nagłówek, jeden ciągły strumień bitowy deflate wg RFC 1951, którego ostatni blok niesie flagę bloku końcowego, oraz czterobajtowa suma kontrolna Adler-32 liczona po wszystkich zdekompresowanych bajtach. ISO 32000-1 §7.4.4 definiuje /FlateDecode dokładnie w tych kategoriach. Gdy inflate dociera do stopki, zwraca Z_STREAM_END i zostawia ewentualne pozostałe dane wejściowe nieprzeczytane w avail_in. Z jego punktu widzenia nic się nie stało, więc nie zgłasza błędu, a bajty za stopką są po prostu ignorowane. Sklejanie członów to legalny pomysł w gzip (RFC 1952 dopuszcza kilka członów w jednym pliku) i stąd zapewne wziąła się ta intuicja, ale zlib nie ma takiej reguły, a PDF nigdy o nią nie prosił

Anatomia członu zlib wg RFC 1950 w strumieniach FlateDecode biblioteki PDFlibPas: dwubajtowy nagłówek, jeden ciągły strumień bitowy deflate, którego ostatni blok niesie flagę bloku końcowego, oraz czterobajtowa stopka Adler-32, na której inflate zwraca Z_STREAM_END, zostawiając pozostałe dane wejściowe nieprzeczytane w avail_in i nie zgłaszając żadnego błędu
Zgodny ze standardem inflater traktuje pierwszą stopkę Adler-32 jako koniec danych, więc granica członu to twarde stop, a wszystko, co producent dokleił za nią, to martwy ciężar, którego żaden czytnik nie zdekoduje

Stary DeflateStream po stronie FPC czytał źródło porcjami po 64 KB i przekazywał każdy kawałek do ZFPCCompress, helpera, który uruchamia własny deflateInit2, kompresuje z Z_FINISH i woła deflateEnd. Każdy kawałek wychodził więc jako kompletny, poprawny, samodzielnie zakończony strumień zlib, a funkcja sklejała je w jeden AnsiString przed zapisem. Wynik wyglądał jak dane Flate, miał poprawny nagłówek i dekodował się bez błędu, ale dekodował się tylko do pierwszych 65 536 bajtów. Po stronie odczytu InflateStream miał lustrzany defekt: wołał ZFPCInflate raz na każde 64 KB skompresowanego wejścia, a każde wywołanie zaczynało od świeżego inflateInit2. Drugi kawałek zaczyna się w środku strumienia bitowego deflate, bez nagłówka zlib, więc nowy inflater go odrzuca, a całkowicie normalny jedno-członowy strumień od innego producenta był dekodowany tylko tak daleko, jak poniosły go pierwsze 64 KB skompresowanych bajtów

Defekt kawałkującego zapisu w DeflateStream po stronie FPC biblioteki PDFlibPas: każdy kawałek 64 KB przechodzi przez ZFPCCompress jako kompletny człon zlib, więc załącznik 1 MiB mieści szesnaście sklejonych członów, czytnik zatrzymuje się na pierwszej stopce po 65 536 bajtach, a GetEmbeddedFileContentToStream nadal zgłasza sukces
Uszkodzenie pozostawało niewidoczne, bo każda warstwa kończyła się sukcesem: słownik załącznika deklarował pełne /Params /Size, dekodowanie nie zgłaszało błędu, a ucięcie zauważał dopiero ktoś, kto otworzył załącznik
// FPC DeflateStream przed v3.539.24 (w wersji uproszczonej):
// ZFPCCompress robi deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// więc każdy kawałek 64 KB staje się osobnym członem 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));

Które wywołania PDF Library for Delphi trafiały na ścieżkę kawałkowania?

Na FPC każdy osadzony plik od 1 MiB wzwyż był zapisywany błędnie, a każdy strumień Flate wyciągnięty przez strumieniowe API był błędnie odczytywany, gdy tylko jego rozmiar po kompresji przekraczał jeden kawałek. TPDFStream.ReadFromStream decyduje, jak kodować nadchodzące dane. Gdy Deflate jest włączone, a Stream.Size ma co najmniej 1048576, dane płyną przez DeflateStream; poniżej tego progu całe źródło trafia do pamięci i wołany jest DeflateStr, jednoshotowy helper, którego problem nigdy nie dotyczył. Łańcuch filtrów ASCII85 plus Flate pomija test rozmiaru i zawsze idzie przez DeflateStream, więc na tej ścieżce każdy ładunek większy niż 64 KB był już rozcinany na kilka członów. Publiczne punkty wejścia zasilające ReadFromStream z włączoną kompresją to funkcje zapisu plików osadzonych:

  • TPDFlib.EmbedFile i TPDFlib.AddEmbeddedFile, które wczytują plik z dysku do strumienia /EmbeddedFile
  • TPDFlib.AddAssociatedFileFromStream i TPDFlib.AddAssociatedFileFromFile, funkcje zapisu plików powiązanych PDF/A-3 używane dla XML faktur elektronicznych i innych danych źródłowych
  • Po stronie odczytu TPDFlib.GetEmbeddedFileContentToStream i GetEmbeddedFileContentToFile, które dekodują przez TPDFStream.WriteDecodedToStream, a stamtąd przez InflateStream

Awaria była cicha na każdej warstwie. Zapisująca strona przechowuje /Params /Size oraz MD5 /CheckSum liczony z oryginalnego pliku, więc słownik załącznika deklarował pełny rozmiar, podczas gdy strumień trzymał szesnaście członów. Budowa Delphi tej samej biblioteki, czytając taki plik, zatrzymywała się czysto na pierwszym Z_STREAM_END i zwracała dokładnie 65 536 bajtów. GetEmbeddedFileContentToStream zwracało 1, bo raportuje, czy dekodowanie zgłosiło błąd, a nie to, czy wynik zgadza się z /Size. Każdy, kto gonił problem dużego dokumentu przez łączenie i dzielenie gigabajtowych PDF-ów, zna ten wzorzec: plik się otwiera, liczba stron się zgadza, a uszkodzenie wychodzi na jaw dopiero, gdy ktoś otwiera załącznik

Jeden stan deflate na wszystkie kawałki

Naprawiony DeflateStream w PDFlibZLib.pas inicjalizuje jeden paszlib.TZStream, podaje każdy kawałek do deflate z Z_NO_FLUSH, a dopiero na końcu opróżnia kompresor przez Z_FINISH, aż ten zwróci Z_STREAM_END. Daje to dokładnie jeden nagłówek, jeden strumień bitowy deflate, którego odwołania wsteczne mogą sięgać przez granice kawałków, i jedną sumę Adler-32 z całego wejścia. Gałąź FPC ma teraz strukturę, którą gałąź Delphi miała zawsze. Zapisuje też wynik na bieżąco, w miarę powstawania, zamiast najpierw sklejać cały skompresowany rezultat w AnsiString, więc zapis nie buduje już drugiej pełnej kopii skompresowanych danych w pamięci, zanim skopiuje je do celu

Naprawiony DeflateStream w PDFlibPas: jeden paszlib.TZStream inicjalizowany raz, każdy kawałek 64 KB podawany z Z_NO_FLUSH i końcowe opróżnienie przez Z_FINISH, co daje dokładnie jeden nagłówek, jeden ciągły strumień bitowy deflate z odwołaniami wstecznymi przekraczającymi granice kawałków i jedną sumę Adler-32 z całego wejścia
Jeden stan zmienia też sposób zapisu wyniku: skompresowane bajty wychodzą, gdy tylko zapełni się bufor, zamiast kumulować się w drugiej pełnej kopii, a PLDeflateLevel dociera teraz także do dużych plików osadzonych na FPC
// FPC DeflateStream od v3.539.24 (ścieżki błędów wycięte)
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);   // ten sam stan, bez przerywania członu
        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                                    // jedna stopka dla całego wejścia
    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 dostała symetryczną przebudowę: jeden inflateInit2, wewnętrzną pętlę wołającą inflate, dopóki bieżący kawałek nie zostanie skonsumowany, a bufor wyjściowy przestanie być pełny, i stop na Z_STREAM_END. Jest jeden efekt uboczny, o którym warto wiedzieć. Stary kawałkujący zapis miał na sztywno poziom 6, a nowy respektuje PLDeflateLevel, więc poziom ustawiony przez TPDFlib.SetCompressionLevel(1..9) obowiązuje teraz na FPC także przy dużych plikach osadzonych. To ma znaczenie, jeśli już dostrajasz kompresję pod archiwizację, jak opisano w artykule o zmniejszaniu rozmiaru plików PDF w Delphi

Jak sprawdzić, czy strumień Flate to pojedynczy człon zlib?

Zdekompresuj go zwykłym dekoderem zlib i sprawdź dwie rzeczy w chwili, gdy zwróci Z_STREAM_END: długość zdekodowana równa się długości źródła, a avail_in wynosi zero. Pozostałość wejścia za znacznikiem końca to znak rozpoznawczy strumienia sklejanego. Poprawkę zweryfikowano właśnie tak: 200 KB danych testowych, obejmujących cztery kawałki po 64 KB, przeszło przez nowy DeflateStream i wyszło jako 534-bajtowy pojedynczy strumień zlib, fabryczny dekoder zlib odzyskał wszystkie 200 000 bajtów bez resztki wejścia, a ten sam test przeszedł na skrośkompilowanym celu FPC i386. Poniższa procedura to wersja FPC tego sprawdzenia, zbudowana wprost na paszlib, żeby nie ufać testowanemu kodowi

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;
    // Jeden człon kończy się dokładnie na ostatnim bajcie wejścia
    Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
  finally
    inflateEnd(strm);
  end;
end;

// Przepchnij 200 000 bajtów przez DeflateStream z domyślnym kawałkiem 64 KB
// i wymagaj jednego członu, który dekoduje się z powrotem do pełnej długości
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');

Na poziomie aplikacji przydatna asercja jest taka, której biblioteka za ciebie nie robi: porównaj to, co wychodzi z załącznika, z zapisanym /Params /Size z chwili, gdy trafił do środka. GetEmbeddedFileIntProperty z tagiem 5 zwraca tę zapisaną wielkość, indeksy plików osadzonych liczą się od 1, a ładunek musi mieć co najmniej 1 MiB, żeby w ogóle ruszyć ścieżkę strumieniowania. Uruchom ten sam test na każdym kompilatorze, z którym wysyłasz produkt, bo oryginalny defekt na Delphi przechodził, a wysypywał się tylko na 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 lub więcej wybiera kawałkowaną ścieżkę DeflateStream w 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;

Czego poprawka nie zmienia?

Gałąź FPC zachowuje swoje pobłażliwe reguły dekodowania i nie naprawia plików, które wcześniejsze budowy FPC już zapisały. Gdy osiągnięte zostaje MaxOutput, InflateStream na FPC ucina na limicie i wraca, podczas gdy gałąź Delphi rzuca ERangeError, a FPC wciąż akceptuje częściowo zdekodowany wynik, gdy inflate zgłasza błąd danych, bo część producentów PDF emituje strumienie ucięte albo z zepsutą sumą kontrolną. PDF zapisany przez budowę FPC sprzed v3.539.24 nadal zawiera sklejone człony, a poprawiony czytnik, jak każdy inny, zatrzymuje się na pierwszym Z_STREAM_END. Nie próbuj leczyć takiego pliku przez dekodowanie i ponowne kodowanie strumienia wewnątrz biblioteki, bo to tylko utrwala ucięcie do 64 KB. Osadź załącznik ponownie z jego oryginalnego źródła. Pętla FPC kończy się też nadal na pierwszym odczycie krótszym niż pełny kawałek, co jest sygnałem końca danych tylko dla strumieni takich jak TFileStream i TMemoryStream, więc własny TStream przekazany do AddAssociatedFileFromStream najbezpieczniej najpierw skopiować do TMemoryStream. Gałąź Delphi, helpery DeflateStr i InflateStr oraz każdy strumień mniejszy niż 1 MiB na zwykłej ścieżce Flate zachowują się dokładnie jak wcześniej

Naprawione DeflateStream i InflateStream po stronie FPC trafiają do wydania v3.539.24 PDF Library for Delphi, celującej w Delphi, C++Buildera i Free Pascal z jednego drzewa źródeł, i od tej wersji duży załącznik powinien z budowy FPC wracać bajt w bajt, tak samo, jak zawsze wracał z Delphi