Tekninen artikkeli

Vahingoittuneiden PDF-xref-taulukoiden uudelleenrakennus Delphissä

Kun PDF:n ristiviittaustaulukko on käyttökelvoton, ratkaisu on ohittaa se kokonaan ja rakentaa se uudelleen tiedoston rungosta. PDFlibPas Delphi PDF Library tekee tämän yhden läpikäynnin token-skannerilla, joka tallentaa jokaisen aidon epäsuoran objektin otsikon, jonka se näkee, palauttaa sitten trailer-sanakirjan ja antaa uudelleenrakennetun taulukon tavalliselle lataajalle

Mikä hajoaa ensimmäisenä, kun PDF vahingoittuu

Ristiviittaustaulukko on PDF:n hauraimmin osa, koska se on ainoa osa, joka tallentaa absoluuttisia tavusiirtymiä. ISO 32000-1 §7.5.4 määrittelee nämä merkinnät kymmennumeroisiksi siirtymiksi tiedoston alusta, ja §7.5.5 sijoittaa startxref-avainsanan lähelle loppua osoittamaan itse taulukkoon. Jokainen näistä luvuista mitätöityy minkä tahansa muokkauksen myötä, joka siirtää tavuja. FTP-istunto, joka ajettiin tekstitilassa ja käänsi CRLF:n, katkennut lataus, jaetulla asemalla vioittunut sektori, eräajotyökalu, joka liitti sisältöä kirjoittamatta asteittaista päivitystä oikein: kaikki nämä jättävät objektidatan täysin luettavaksi ja indeksin osoittamaan roskaan

Siksi "tiedosto on vahingoittunut ja sitä korjataan" on niin yleinen valintaikkuna. Tavut ovat lähes aina yhä paikallaan. Se, mikä on kadonnut, on kartta. Uudelleenrakennus ei siis ole kadonneen datan rikostekninen palautus, se on indeksin uudelleenrakennus, joka voidaan johtaa rungosta, ja se onnistuu paljon useammin kuin käyttäjät odottavat, koska kallis sisältö, sivupuut, fontit ja kuvat, on koskematon

Miksi N 0 obj -haku löytää vääriä osumia?

Naiivi uudelleenrakennus etsii raakoista tavuista kaavaa "kokonaisluku, kokonaisluku, obj" ja tallentaa jokaisen osuman. Se löytää liikaa. PDF on säiliömuoto, ja kolme tiedoston aluetta ovat läpinäkymättömiä objektikieliopille: kommentit (§7.2), merkkijonot (§7.3.4) ja virtadata (§7.3.8). Mikä tahansa niistä voi sisältää tavuja, jotka luetaan täsmälleen kuin objektin otsikko, eikä mikään niistä ole objektin otsikko. Kuvateksti kirjaimellisessa merkkijonossa, jäljelle jäänyt debug-kommentti tai kaksi megatavua Flate- tai DCT-tulostetta tuottavat kaikki mielellään jotain, joka näyttää kuin 99 0 obj

const
  Trap: AnsiString =
    '4 0 obj'#10 +
    '(a caption that mentions 88 0 obj)'#10 +   // literal string, not an object
    'endobj'#10 +
    '% 77 0 obj left over from a debug dump'#10 +  // comment, not an object
    '5 0 obj'#10 +
    '<< /Length 2097152 >>'#10 +
    'stream'#10 +
    { two MiB of compressed bytes that contain the byte sequence
      99 0 obj and, further along, a complete endstream }
    'endstream'#10 +
    'endobj'#10;

Jokainen väärä merkintä maksaa kahdesti. Se saastuttaa uudelleenrakennetun taulukon objektinumerolla, jota ei ole olemassa, ja se voi peittää saman numeron todellisen objektin, joka esiintyy myöhemmin tiedostossa. PDFlibPas ei siis kaavantunnista lainkaan. Se tokenisoi, mikä tarkoittaa, että se tietää aina, ovatko kohdistimen alla olevat tavut koodia vai hyötykuormaa, ja hyötykuorma ohitetaan sitä koskaan tulkitsematta

Yhden läpikäynnin tilakone 64 KiB:n lohkoissa

PDFlibPas skannaa koko tiedoston täsmälleen kerran, 64 KiB:n lohkoissa, tilakoneella, joka on rakennettu ISO 32000-1 §7.2:n token-sääntöjen ja §7.3.10:n epäsuoran objektin syntaksin varaan. Token päättyy välilyöntiin tai johonkin erotinmerkkiin, ja objektin otsikko tallennetaan vain, kun täydellinen sekvenssi positiivisesta objektinumerosta, ei-negatiivisesta sukupolvinumerosta ja paljaasta obj-avainsanasta on nähty. Tallennettu siirtymä on objektinumerotokenin alku, mihin ristiviittausmerkinnän täytyy osoittaa, ei obj-avainsanan sijaintiin

function RebuildIsWhiteSpace(Value: Byte): Boolean;
begin
  Result := (Value = 0) or (Value = 9) or (Value = 10) or
            (Value = 12) or (Value = 13) or (Value = 32);
end;

function RebuildIsDelimiter(Value: Byte): Boolean;
begin
  Result := (Value = Ord('(')) or (Value = Ord(')')) or
            (Value = Ord('<')) or (Value = Ord('>')) or
            (Value = Ord('[')) or (Value = Ord(']')) or
            (Value = Ord('{')) or (Value = Ord('}')) or
            (Value = Ord('/')) or (Value = Ord('%'));
end;

Tärkeä yksityiskohta on se, että token-tila ja merkkijonotila säilyvät lohkorajan yli. Otsikko, joka hajaantuu 65536-tavuisen rajan yli, tunnistetaan silti, koska osittainen token, odottava kokonaislukupari ja merkkijonossa-liput kaikki kulkeutuvat seuraavaan lohkoon. Puskurit ovat kiinteitä: 64 KiB skannaukselle, 32 tavua pisimmälle tokenille, jolla voi ylipäätään olla merkitystä, ja ainoat taulukot, jotka kasvavat tiedoston mukana, ovat objektinumero-, sukupolvinumero- ja 64-bittiset siirtymälistat, jotka ovat suhteessa todelliseen objektimäärään eivätkä tiedoston kokoon. Käytännössä skannaus tekee peräkkäisiä lukuja ja korkeintaan kaksi selkeää siirtymähakua koko dokumentin yli, mikä tekee siitä käyttökelpoisen artikkelissa suorasta pääsystä yhdistämiseen ja jakamiseen käsitellyillä satojen megatavujen syötteillä

Miksi virtaan ei voi luottaa, että se päättyy endstream-avainsanaan?

Koska virtadata on mielivaltaisia tavuja, ja mielivaltaiset tavut voivat vahingossa tavata sanan endstream. Virta, joka alkaa stream-avainsanan jälkeen, täytyy ohittaa läpinäkymättömänä datana, kunnes se aidosti päättyy, mutta sulkevan avainsanan ensimmäinen esiintymä on vain ehdokas. PDFlibPas ratkaisee tämän vaatimalla vahvistuksen: endstream-token hyväksytään virran todelliseksi lopuksi vain, kun seuraava ei-välilyönti-token on itsenäinen endobj, sekvenssi, jonka §7.3.8 vaatii virtaobjektin ympärille. Sattumanvarainen osuma pakatun datan sisällä ei lähes koskaan saa tuota jatkoa, joten skanneri pysyy virran sisällä ja jatkaa. Kaksi pienempää sääntöä ovat yhtä tärkeitä. stream-avainsana siirtää virtatilaan vain, kun se on paljas avainsana, joten nimiobjekti kuten /stream sanakirjassa ei koskaan laukaise sitä. Ja obj- tai trailer-token huomioidaan vain, kun token ei ylittänyt 32 tavun rajaa eikä alkanut kauttaviivalla. Ilman näitä kahta vartijaa resurssisanakirja väärillä avainnimillä riittäisi suistamaan skannauksen raiteiltaan, mikä on täsmälleen sitä vihamielisen syötteen luokkaa, joka käsitellään muistiossa epäluotettavien PDF-tiedostojen turvallisesta jäsentämisestä

Trailer-sanakirjan todellisen lopun löytäminen

Objektien palauttaminen on vain puolet työstä, koska lataaja tarvitsee yhä trailerin löytääkseen /Root-avaimen. PDFlibPas muistaa skannauksen aikana löydetyt 64 viimeisintä trailer-avainsanan sijaintia ja validoi ne taaksepäin, tuorein ensin, joten uusin käyttökelpoinen trailer voittaa, ja irrallinen avainsana, jota ei seuraa sanakirja, epäonnistuu yksinkertaisesti validoinnissa ja siirtyy edelliseen ehdokkaaseen. Jokainen ehdokas luetaan 1 MiB:n rajalla, ja sanakirjan loppu paikannetaan seuraamalla sisäkkäisiä <<- ja >>-syvyyksiä yhdessä kirjaimellisten merkkijonojen escape-merkkien, heksadesimaalisten merkkijonojen ja kommenttien kanssa

// A naive reader that stops at the first '>>' truncates this trailer,
// and a fixed 2048-byte window can cut it in half on a large one
'trailer'#10 +
'<< /Size 5 /Root 1 0 R' +
'   /Custom << /Text (value >> preserved) >> >>'#10

Syvyyden seuranta ei ole akateemista. Katkennut trailer, joka menettää /Encrypt-avaimen, muuttaa palautettavissa olevan salatun dokumentin avautumattomaksi, ja /Info- tai mukautetun alisanakirjan menettäminen hylkää hiljaa metatietoa, josta jokin alavirran järjestelmä saattaa olla riippuvainen. Jos tiedosto on salattu, palautettu trailer on se, mikä sallii tavallisen tunnistetietopolun ajaa, ja uudelleenyrityssemantiikka on sama, joka kuvataan artikkelissa salatun dokumentin lataamisesta

Mitä uudelleenrakennus ei voi antaa takaisin

Uudelleenrakennus on parhaan yrityksen periaate, ja sen rajoista rehellinen oleminen on osa sen julkaisua. Kolme tapausta epäonnistuu suoraan. Objektivirtojen sisään pakatut objektit (§7.5.7) eivät ole yksitellen näkyviä tavuskannaukselle, joten jos säiliö säilyy mutta sen ristiviittausvirta (§7.5.8) ei, säiliön sisältämiä objekteja ei indeksoida uudelleenrakennuksessa. Tiedosto, jonka runko todella vahingoittui pelkän väärän indeksoinnin sijaan, tuottaa otsikoita, joiden sisältö ei enää jäsenny. Ja tiedostolla, jossa ei ole palautettavissa olevaa trailer-avainsanaa eikä luettavaa katalogia, ei ole mitään, mihin ankkuroida dokumenttipuu, riippumatta siitä, kuinka monta objektin otsikkoa löydettiin

Kaksoiskappaleina esiintyvät objektinumerot ovat kiinnostava välitapaus. Asteittain päivitetty tiedosto sisältää oikeutetusti saman objektinumeron useita sukupolvia, ja säilynyt ristiviittausketju on ainoa tallenne siitä, mikä niistä oli ajantasainen. Uudelleenrakennuksella ei ole tuota ketjua, joten se tallentaa jokaisen näkemänsä otsikon tiedostojärjestyksessä ja ratkaisee sitten objektinumeron mukaan. Yleensä myöhempi versio voittaa, mikä on yleensä oikein, mutta dokumentti, joka päivitettiin ja sitten osittain peruutettiin, voi palata hienovaraisesti erilaisena kuin alkuperäinen xref kuvasi. Linearisoidut tiedostot kantavat saman varauksen toisesta suunnasta: ensimmäisen sivun asettelu ja vihjetaulukot ovat merkityksettömiä heti, kun indeksi on regeneroitu, joten korjattua tiedostoa tulisi käsitellä tavallisena, ei-linearisoituna dokumenttina

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('truncated-invoice.pdf', '') = 1 then
    begin
      if Pdf.GetDocumentRepaired = 1 then
        LogWarning('xref was unusable; the table was reconstructed');
      if Pdf.PageCount > 0 then
        Pdf.SaveToFile('recovered-invoice.pdf');   // writes a clean xref
    end;
  finally
    Pdf.Free;
  end;
end;

Varajärjestelmä on automaattinen: PDFlibPas ajaa raa'an skannauksen aina, kun ristiviittausketjua ei voida lukea, ja myös silloin, kun jokainen käytössä oleva merkintä väittää siirtymän olevan nolla, mikä on merkki taulukosta, joka kirjoitettiin mutta jota ei koskaan täytetty. GetDocumentRepaired palauttaa arvon 1, kun tuo polku ajettiin, ja se kannattaa kirjata sen sijaan, että se ohitettaisiin, koska dokumentti, joka latautui uudelleenrakennuksen kautta, tulisi tallentaa uudelleen puhtaaseen tiedostoon sen sijaan, että se jätettäisiin putkeen kuin mitään ei olisi tapahtunut. Sen tallentaminen kirjoittaa tuoreen, johdonmukaisen ristiviittaustaulukon, mikä on halvin mahdollinen korjaus jokaiselle alavirran kuluttajalle

Tässä esitetty uudelleenrakennuspolku, GetDocumentRepaired-lippu ja suoratoistava lataaja ovat osa PDFlibPas Delphi PDF Library -kirjastoa, yhdessä jäsennys-, renderöinti- ja allekirjoitus-API:en kanssa, joita käsitellään muualla tässä blogissa