Tekninen artikkeli

Paloiteltu zlib FPC:llä: FlateDecode vaatii yhden streamin

Ennen versiota v3.539.24 PDF Library for Delphin Free Pascal -kooste paketoi suuret streamit 64 kilotavun paloissa ja antoi jokaiselle palalle oman zlib-otsikon ja tarkistussumman, joten 1 MiB:n liitetiedostosta tuli päistikkaan liimattujen zlib-jäsenten ketju. ISO 32000-1:n FlateDecode odottaa täsmälleen yhtä zlib-streamia, ja standardinmukainen dekooderi pysähtyy ensimmäiseen streamin lopun merkkiin, eli kaikki ensimmäisten 65 536 tavun jälkeen katosi hiljaa. Korjaus pitää yhden zlib-tilan elossa jokaisen palan yli sekä DeflateStreamissa että InflateStreamissa

Bugi asusteli vain yksikön FPC-puolella ja vain paloitellulla streamauspolulla, ja täsmälleen siksi se selviytyi: Delphi-haara toimi aina oikein, merkkijonopohjaiset apurit toimivat aina oikein, eikä pienet testikuormat koskaan ylettäneet paloiteltuun koodiin saakka. Vika on läheistä sukua viidelle FPC-siirtobugille, jotka Delphi oli pitänyt piilossa, paitsi että tässä Delphi-kooste ei suojellut mitään. FPC-haara oli yksinkertaisesti kirjoitettu väärän mielikuvan varaan siitä, mitä zlib-stream oikeastaan on

Miksi muut PDF-lukijat katkaisevat paloitellun zlib-streamin?

Siksi, että RFC 1950:n määrittelemä zlib-stream on yksi kontti, ei niiden sarja, ja standardinmukainen inflater tulkitsee ensimmäisen Adler-32-loppusumman datan lopuksi. Muoto koostuu kaksitavuisesta otsikosta, yhtenäisestä RFC 1951:n deflate-bittivirrasta, jonka viimeinen lohko kantaa viimeistä lohkoa merkitsevää lippua, ja nelitavuisesta Adler-32-tarkistussummasta kaikkien pakkamattomien tavujen yli. ISO 32000-1 §7.4.4 määrittelee /FlateDecode-suodattimen juuri noin. Kun inflate saavuttaa loppusumman, se raportoi Z_STREAM_END-tilan ja jättää mahdollisen jäljelle jäävän syötteen lukematta avail_in-kenttään. Sen näkökulmasta mikään ei ole vialla, joten se ei nosta virhettä, ja loppusumman jälkeiset tavut yksinkertaisesti ohitetaan. Ketjutetut jäsenet ovat gzipissä ihan laillinen idea (RFC 1952 sallii useita jäseniä yhdessä tiedostossa), ja tuskin intuitio on tullut mistään muualta, mutta zlibillä ei ole sellaista sääntöä, eikä PDF koskaan pyytänytkään

RFC 1950:n zlib-jäsenen anatomia PDFlibPasin FlateDecode-streamissa: kaksitavuinen otsikko, yhtenäinen deflate-bittivirta, jonka viimeinen lohko kantaa viimeisen lohkon lippua, ja nelitavuinen Adler-32-loppusumma, jossa inflate palauttaa Z_STREAM_ENDin jättäen mahdollisen jäljelle jäävän syötteen lukematta avail_in-kenttään ilman virhettä
Standardinmukainen inflater tulkitsee ensimmäisen Adler-32-loppusumman datan lopuksi, joten jäsenraja on kova pysähdys ja kaikki sen jälkeen liimattu on kuollutta painoa, jota mikään lukija ei pura

Vanha FPC:n DeflateStream luki lähdestreamia 64 kilotavua kerrallaan ja antoi jokaisen palan ZFPCCompress-apurille, joka ajaa oman deflateInit2-alustuksen, pakkaa Z_FINISH-lipulla ja lopettaa deflateEnd-kutsuun. Jokainen pala tuli siis ulos täytenä, kelvollisena ja itseensä päättyvänä zlib-streamina, ja funktio liitti palat yhdeksi AnsiString-merkkijonoksi ennen kirjoittamista. Tulos näytti Flate-datalta, siinä oli oikea otsikko, ja se purkautui ilman virhettä, mutta vain ensimmäisiin 65 536 tavuun. Lukupuolella InflateStreamissa oli peilikuvasama vika: se kutsui ZFPCInflate-apuria kerran jokaista 64 kilotavua pakattua syötettä kohden, ja jokainen kutsu käynnisti tuoreen inflateInit2-alustuksen. Toinen pala alkaa deflate-bittivirran keskeltä ilman zlib-otsikkoa, joten uusi inflater hylkää sen, ja toiselta tuottajalta saapunut täysin normaali yhden jäsenen stream purkautui vain niin pitkälle kuin sen ensimmäiset 64 kilotavua pakattuja tavuja ulottuivat

Paloitellun kirjoittajan vika PDFlibPasin FPC:n DeflateStreamissa: jokainen 64 kilotavun pala kulkee ZFPCCompressin läpi täytenä zlib-jäsenenä, joten 1 MiB:n liite sisältää kuusitoista liimattua jäsentä, lukija pysähtyy ensimmäiseen loppusummaan 65 536 tavun jälkeen ja GetEmbeddedFileContentToStream raportoi silti onnistumista
Vahinko pysyi näkymättömänä, koska jokainen kerros onnistui: liitteen sanakirja mainosti täyttä /Params /Size -kokoa, purku ei nostanut virhettä, ja vain liitteen avaaja huomasi katkenneen
// FPC:n DeflateStream ennen v3.539.24:ää (yksinkertaistettu):
// ZFPCCompress ajaa deflateInit2 / deflate(Z_FINISH) / deflateEnd,
// joten jokainen 64 kilotavun pala muuttuu erilliseksi zlib-jäseneksi
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));

Mitkä PDF Library for Delphin kutsut päätyivät paloitellulle polulle?

FPC:llä mikä tahansa vähintään 1 MiB:n upotettu tiedosto kirjoittui väärin, ja mikä tahansa streaming-API:n kautta purettu Flate-stream luettiin väärin heti kun sen pakattu koko ylitti yhden palan. TPDFStream.ReadFromStream päättää, miten saapuva data koodataan. Kun Deflate on tosi ja Stream.Size >= 1048576, data kulkee DeflateStreamin läpi; kynnyksen alla koko lähde luetaan muistiin ja kutsutaan DeflateStr-apuria, yksittäislaukauksen apuria, jota vika ei koskaan koskettanut. ASCII85:n ja Faten yhdistävä suodatinaketju hyppää kokotestin yli ja menee aina DeflateStreamin kautta, joten sillä polulla mikä tahansa yli 64 kilotavun kuorma oli jo jaettu useiksi jäseniksi. Julkiset sisääntulot, jotka syöttävät ReadFromStreamia pakkaus päällä, ovat upotettujen tiedostojen kirjoittajat:

  • TPDFlib.EmbedFile ja TPDFlib.AddEmbeddedFile, jotka lukevat tiedoston levyltä /EmbeddedFile-streamiin
  • TPDFlib.AddAssociatedFileFromStream ja TPDFlib.AddAssociatedFileFromFile, PDF/A-3:n liitetiedostojen kirjoittajat sähkölaskun XML:lle ja muille lähdetiedoille
  • Lukupuolella TPDFlib.GetEmbeddedFileContentToStream ja GetEmbeddedFileContentToFile, jotka purkavat TPDFStream.WriteDecodedToStreamin kautta ja sieltä edelleen InflateStreamin kautta

Virhe oli hiljainen joka kerroksessa. Kirjoittaja tallentaa /Params /Size-koon ja alkuperäisestä tiedostosta lasketun MD5-/CheckSumin, joten liitteen sanakirja mainosti täyttä kokoa samalla kun streamissä oli kuusitoista jäsentä. Saman kirjaston Delphi-kooste luki kyseisen tiedoston, pysähtyi siististi ensimmäiseen Z_STREAM_END-merkkiin ja palautti tarkalleen 65 536 tavua. GetEmbeddedFileContentToStream palautti 1, koska se raportoi, nousiko purusta virhettä, eikä sitä, täsmääkö tuotos /Sizeen. Jokainen, joka on jahdannut suuren asiakirjan ongelmaa gigatavujen PDFien yhdistämisen ja pilkkomisen kautta, tuntee tämän kaavan: tiedosto avautuu, sivumäärä on oikein, ja vahinko näkyy vasta kun joku avaa liitteen

Yksi deflate-tila jokaisen palan yli

Korjattu DeflateStream tiedostossa PDFlibZLib.pas alustaa yhden paszlib.TZStream-rakenteen, syöttää jokaisen palan deflate-funktioon Z_NO_FLUSH-lipulla ja vasta lopussa tyhjentää pakkaajan Z_FINISH-lipulla, kunnes paluuarvona on Z_STREAM_END. Siitä syntyy täsmälleen yksi otsikko, yksi deflate-bittivirta, jonka takaviittaukset ylettävät palojen rajojen yli, ja yksi Adler-32 koko syötteestä. FPC-haaralla on nyt sama rakenne, jota Delphi-haara on aina käyttänyt. Se kirjoittaa myös tuotosta ulos sitä mukaa kun se syntyy sen sijaan, että ensin kokoaisi koko pakatun tuloksen AnsiString-merkkijonoon, joten kirjoittaja ei enää rakenna toista täyttä kopiota pakatusta datasta muistiin ennen kuin kopioi sen kohteeseen

Korjattu DeflateStream PDFlibPasissa: yksi paszlib.TZStream alustetaan kerran, jokainen 64 kilotavun pala syötetään Z_NO_FLUSHilla ja viimeinen tyhjennys ajetaan Z_FINISHilla, jolloin syntyy täsmälleen yksi otsikko, yksi yhtenäinen deflate-bittivirta, jonka takaviittaukset ylittävät palorajat, ja yksi Adler-32 koko syötteestä
Yksi tila muuttaa myös tuotoksen kirjoittamista: pakatut tavut poistuvat jo jonon täyttyessä sen sijaan että kertyisivät toiseen täyteen kopioon, ja PLDeflateLevel ylettää nyt suuriin upotettuihin tiedostoihin myös FPC:llä
// FPC:n DeflateStream versiosta v3.539.24 alkaen (virhepolut karsittu)
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);   // sama tila, ei jäsenkatkoa
        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                                    // yksi loppusumma koko syötteelle
    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 sai symmetrisen uudelleenkirjoituksen: yksi inflateInit2-alustus, sisäsilmukka, joka kutsuu inflateia kunnes nykyinen pala on kulutettu ja tuotosjono ei ole enää täynnä, ja pysähdys Z_STREAM_END-merkkiin. Yksi sivuvaikutus kannattaa tietää. Vanha paloitteleva kirjoittaja kovakoodasi tason 6, uusi noudattaa PLDeflateLevel-asetusta, joten TPDFlib.SetCompressionLevel(1..9) -kutsulla asetettu taso koskee nyt suuria upotettuja tiedostoja myös FPC:llä. Sillä on väliä, jos olet jo hienosäätänyt pakkausta arkistotuotosta varten niin kuin kerrotaan artikkelissa PDF-tiedoston koon pienentämisestä Delphissä

Miten varmistat, että Flate-stream on yksi zlib-jäsen?

Pura se tavallisella zlib-dekooderilla ja tarkista kaksi asiaa, kun paluuarvona on Z_STREAM_END: puretun datan pituus on lähdepituus ja avail_in on nolla. Loppumerkin jälkeen jäänyt syöte on ketjutetun streamin tunnusmerkki. Korjaus varmistettiin näin: 200 kilotavun testidata, joka kattaa neljä 64 kilotavun palaa, kulki uuden DeflateStreamin läpi ja tuli ulos 534-tavuisena yhtenä zlib-streamina, vakio-zlib-dekooderi sai kaikki 200 000 tavua takaisin ilman jäljelle jäänyttä syötettä, ja sama tarkistus meni läpi i386-ristikäännetyllä FPC-kohteella. Alla oleva rutiini on kyseisen tarkistuksen FPC-versio, rakennettu suoraan paszlibin päälle, joten se ei luota testattavaan koodiin

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;
    // Yksi jäsen päättyy täsmälleen viimeiseen syötetavuun
    Result:= (Status = Z_STREAM_END) and (strm.avail_in = 0);
  finally
    inflateEnd(strm);
  end;
end;

// Viedään 200 000 tavua DeflateStreamin läpi oletuskokoisella 64 kilotavun palalla
// ja vaaditaan yhtä jäsentä, joka purkautuu täyteen pituuteen
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');

Sovellustasolla hyödyllisin väite on se, jota kirjasto ei tee puolestasi: vertaa liitteen tuotosta sisäänkirjoitettuun /Params /Size-kokoon. GetEmbeddedFileIntProperty tunnisteella 5 palauttaa kirjatun koon, upotettujen tiedostojen indeksit alkavat ykkösestä, ja kuorman täytyy olla vähintään 1 MiB, jotta streamauspolku pääsee toimimaan. Aja sama testi jokaisella mukana toimitettavalla kääntäjällä, sillä alkuperäinen vika meni Delphillä läpi ja kaatui vain FPC:llä

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');
    // Vähintään 1 MiB vie ReadFromStreamin paloitellulle DeflateStream-polulle
    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;

Mitä korjaus ei muuta?

FPC-haara säilyttää sallivat purkusääntönsä, eikä se korjaa tiedostoja, joita aiemmat FPC-koosteet jo kirjoittivat. Kun MaxOutput täyttyy, FPC:n InflateStream katkaisee rajalla ja palaa, kun taas Delphi-haara nostaa ERangeError-poikkeuksen, ja FPC hyväksyy edelleen osittain purkatun tuotoksen, kun inflate raportoi datavirheestä, sillä jotkin PDF-tuottajat lähettävät katkenneita tai tarkistussummaltaan rikkoutuneita streameja. Ennen v3.539.24:tä olleen FPC-koosteen kirjoittama PDF sisältää edelleen ketjutettuja jäseniä, ja korjattu lukija pysähtyy ensimmäiseen Z_STREAM_END-merkkiin aivan kuten jokainen muukin lukija. Älä yritä parantaa sellaista tiedostoa purkamalla ja uudelleenkoodaamalla streamia kirjaston sisällä, sillä se vain tekee 64 kilotavun katkaisusta pysyvän. Upota liite sen sijaan uudelleen alkuperäisestä lähteestään. FPC-silmukka päättyy myös edelleen ensimmäiseen lukuun, joka palauttaa vajaan palan, ja se on datan lopun merkki vain TFileStreamin ja TMemoryStreamin kaltaisille streameille, joten AddAssociatedFileFromStreamille annettu oma TStream on turvallisinta kopioida ensin TMemoryStreamiin. Delphi-haara, DeflateStr- ja InflateStr-apurit sekä jokainen alle 1 MiB:n stream tavallisella Flate-polulla toimivat täsmälleen kuten ennenkin

Korjatut FPC:n DeflateStream ja InflateStream toimitetaan PDF Library for Delphin versiossa v3.539.24, joka tähtää Delphiin, C++Builderiin ja Free Pascaliin yhdestä lähdekoodipuusta, ja jossa suuren liitteen pitäisi nyt palata FPC-koosteelta tavu tavulta, aivan samoin kuin se on aina palannut Delphiltä