Keď je cross-reference tabuľka PDF nepoužiteľná, oprava spočíva v jej úplnom ignorovaní a rebuild-e z tela súboru. PDFlibPas Delphi PDF Library to robí jedným prechodom token scanner-a, ktorý zaznamená každú skutočnú hlavičku indirect objektu, ktorú uvidí, potom obnoví trailer slovník a odovzdá rekonštruovanú tabuľku bežnému loaderu
Čo sa pri poškodení PDF pokazí prvé
Cross-reference tabuľka je najkrehkejšia časť PDF, pretože je to jediná časť, ktorá ukladá absolútne bajtové offsety. ISO 32000-1 §7.5.4 definuje tieto záznamy ako desaťciferné offsety od začiatku súboru a §7.5.5 umiestňuje kľúčové slovo startxref blízko konca, ukazujúce na samotnú tabuľku. Každé z týchto čísel znehodnotí akákoľvek úprava, ktorá posunie bajty. FTP relácia bežiaca v textovom móde, ktorá prekladala CRLF, prerušené sťahovanie, sektor, ktorý sa pokazil na zdieľanom disku, dávkový nástroj, ktorý pripájal bez správneho zápisu inkrementálnej aktualizácie: všetky ponechajú dáta objektov dokonale čitateľné a index ukazujúci na odpad
Preto je „súbor je poškodený a opravuje sa" taký bežný dialóg. Bajty sú takmer vždy stále tam. Chýba mapa. Rekonštrukcia teda nie je forenzné obnovenie stratených dát, je to rebuild indexu, ktorý sa dá odvodiť z tela, a darí sa jej oveľa častejšie, než používatelia očakávajú, pretože nákladný obsah — stromy stránok, fonty a obrázky — je nedotknutý
Prečo hľadanie N 0 obj nachádza falošné zhody?
Naivný rebuild prehľadá surové bajty na vzor „celé číslo, celé číslo, obj" a zaznamená každý zásah. Nájde príliš veľa. PDF je kontajnerový formát a tri oblasti súboru sú pre gramatiku objektov nepriehľadné: komentáre (§7.2), reťazce (§7.3.4) a dáta streamu (§7.3.8). Ktorákoľvek z nich môže obsahovať bajty, ktoré sa čítajú presne ako hlavička objektu, a žiadna z nich hlavičkou objektu nie je. Popis v literálnom reťazci, zabudnutý debug komentár alebo dva megabajty Flate či DCT výstupu — každé z toho ochotne vyprodukuje niečo, čo vyzerá ako 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;
Každý falošný záznam stojí dvakrát. Znečistí rebuildnutú tabuľku číslom objektu, ktoré neexistuje, a môže zatieniť skutočný objekt s rovnakým číslom, ktorý sa objaví neskôr v súbore. PDFlibPas preto vôbec nerobí pattern-matching. Tokenizuje, čo znamená, že vždy vie, či bajty pod kurzorom sú kód alebo payload, a payload sa preskočí bez toho, aby sa kedy interpretoval
Jednoprechodový stavový automat cez 64 KiB bloky
PDFlibPas prehľadá celý súbor presne raz, v 64 KiB blokoch, so stavovým automatom postaveným na pravidlách tokenov z ISO 32000-1 §7.2 a syntaxe indirect objektov z §7.3.10. Token končí na bielom znaku alebo na jednom z oddeľovacích znakov, a hlavička objektu sa zaznamená len vtedy, keď bola videná kompletná sekvencia kladného čísla objektu, nezáporného čísla generácie a holého kľúčového slova obj. Zaznamenaný offset je začiatok tokenu čísla objektu, čo je to, na čo musí ukazovať cross-reference záznam, nie pozícia kľúčového slova obj
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;
Dôležitým detailom je, že stav tokenu a stav reťazca prežijú hranicu bloku. Hlavička, ktorá sa rozprestiera cez hranicu 65536 bajtov, je stále rozpoznaná, pretože čiastočný token, čakajúci pár celých čísel a príznaky in-string sa všetky prenesú do ďalšieho bloku. Buffre sú pevné: 64 KiB pre scan, 32 bajtov pre najdlhší token, na ktorom vôbec môže záležať, a jediné polia, ktoré rastú so súborom, sú zoznamy čísla objektu, čísla generácie a 64-bitových offsetov, ktoré sú úmerné skutočnému počtu objektov, nie veľkosti súboru. V praxi scan vykonáva sekvenčné čítania a najviac dva explicitné seeky cez celý dokument, čo je to, čo ho robí použiteľným na vstupoch veľkých niekoľko stoviek megabajtov, diskutovaných v článku o priamom prístupe pri merge a split
Prečo sa nedá dôverovať, že stream skončí na endstream?
Pretože dáta streamu sú ľubovoľné bajty a ľubovoľné bajty môžu náhodou vysloviť endstream. Stream, ktorý začína po kľúčovom slove stream, sa musí preskočiť ako nepriehľadné dáta, kým skutočne neskončí, ale prvý výskyt uzatváracieho kľúčového slova je len kandidát. PDFlibPas to rieši vyžadovaním potvrdenia: token endstream sa prijme ako skutočný koniec streamu len vtedy, keď je ďalší nebiely token samostatný endobj, sekvencia, ktorú §7.3.8 vyžaduje okolo stream objektu. Náhodný zásah vnútri komprimovaných dát takmer nikdy nemá toto pokračovanie, takže scanner ostáva vnútri streamu a pokračuje ďalej. Dve menšie pravidlá majú rovnakú váhu. Kľúčové slovo stream vstúpi do stavu streamu len vtedy, keď je to holé kľúčové slovo, takže name objekt ako /stream v slovníku ho nikdy nespustí. A token obj alebo trailer sa uzná len vtedy, keď token nepresiahol 32-bajtový strop a nezačínal solidusom. Bez týchto dvoch ochranných opatrení by resource slovník so zlými menami kľúčov stačil na vykoľajenie scanu, čo je presne trieda nepriateľského vstupu pokrytá v poznámkach o bezpečnom parsovaní nedôveryhodných PDF
Nájdenie skutočného konca trailer slovníka
Obnovenie objektov je len polovica úlohy, pretože loader stále potrebuje trailer, aby našiel /Root. PDFlibPas si pamätá posledných 64 pozícií kľúčového slova trailer nájdených počas scanu a validuje ich spätne, od najnovšieho, takže vyhráva najnovší použiteľný trailer a bludné kľúčové slovo, za ktorým nenasleduje slovník, jednoducho neprejde validáciou a prepadne na predchádzajúceho kandidáta. Každý kandidát sa číta so stropom 1 MiB a koniec slovníka sa nájde sledovaním vnorenej hĺbky << a >> spolu s escape sekvenciami literálnych reťazcov, hexadecimálnymi reťazcami a komentármi
// 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
Sledovanie hĺbky nie je akademická záležitosť. Skrátený trailer, ktorý stratí /Encrypt, zmení obnoviteľný šifrovaný dokument na neotvoriteľný, a strata /Info alebo vlastného pod-slovníka potichu zahodí metadáta, na ktoré sa downstream systém môže spoliehať. Ak je súbor šifrovaný, obnovený trailer je to, čo umožňuje bežať bežnej ceste prihlasovacích údajov, a semantika opakovania je rovnaká, ako je popísaná v článku o načítaní šifrovaného dokumentu
Čo vám rekonštrukcia nedokáže vrátiť
Rekonštrukcia je snaha najlepšieho úsilia a byť úprimný o jej limitoch je súčasťou jej nasadenia. Tri prípady zlyhajú úplne. Objekty zabalené vnútri object streamov (§7.5.7) nie sú individuálne viditeľné pre bajtový scan, takže ak kontajner prežije, ale jeho cross-reference stream (§7.5.8) nie, objekty, ktoré drží, rebuild neindexuje. Súbor, ktorého telo bolo naozaj poškodené, nie len zle zaindexované, vyprodukuje hlavičky, ktorých obsah sa už neparsuje. A súbor bez obnoviteľného kľúčového slova trailer a bez čitateľného katalógu nemá k čomu ukotviť strom dokumentu, bez ohľadu na to, koľko hlavičiek objektov sa našlo
Zaujímavým stredným prípadom sú duplicitné čísla objektov. Inkrementálne aktualizovaný súbor legitímne obsahuje niekoľko generácií toho istého čísla objektu a preživšia cross-reference reťaz je jediný záznam o tom, ktorá bola aktuálna. Rebuild tento reťaz nemá, takže zaznamená každú hlavičku, ktorú uvidí, v poradí súboru, a potom rieši podľa čísla objektu. Zvyčajne vyhráva neskoršia revízia, čo je väčšinou správne, ale dokument, ktorý bol aktualizovaný a potom čiastočne vrátený späť, sa môže vrátiť jemne odlišný od toho, čo pôvodný xref popisoval. Linearizované súbory nesú rovnaké upozornenie z opačnej strany: rozloženie prvej stránky a hint tabuľky sú bezvýznamné, akonáhle je index znovu vygenerovaný, takže s opraveným súborom by sa malo zaobchádzať ako s obyčajným, nelinearizovaným dokumentom
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;
Fallback je automatický: PDFlibPas spustí surový scan vždy, keď sa cross-reference reťaz nedá prečítať, a tiež vtedy, keď každý používaný záznam tvrdí, že offset je nula, čo je podpis tabuľky, ktorá bola zapísaná, ale nikdy nevyplnená. GetDocumentRepaired vráti 1, keď táto cesta bežala, a oplatí sa to logovať, nie ignorovať, pretože dokument, ktorý sa načítal cez rekonštrukciu, by mal byť znovu uložený do čistého súboru, nie ponechaný v pipeline, akoby sa nič nestalo. Jeho uloženie zapíše čerstvú, konzistentnú cross-reference tabuľku, čo je najlacnejšia možná oprava pre každého downstream konzumenta
Cesta rekonštrukcie, príznak GetDocumentRepaired a tu ukázaný streamovací loader sú súčasťou PDFlibPas Delphi PDF Library, popri API na parsovanie, renderovanie a podpisovanie pokrytých inde na tomto blogu