Když je tabulka cross-reference v PDF nepoužitelná, řešením je zcela ji ignorovat a obnovit ji z těla souboru. PDFlibPas Delphi PDF Library to dělá pomocí jednoprůchodového token skeneru, který zaznamená každou skutečnou hlavičku nepřímého objektu, kterou uvidí, poté obnoví slovník traileru a předá rekonstruovanou tabulku běžnému loaderu
Co se rozbije jako první, když je PDF poškozené
Tabulka cross-reference je nejkřehčí částí PDF, protože je to jediná část, která ukládá absolutní bajtové offsety. ISO 32000-1 §7.5.4 definuje tyto záznamy jako desetimístné offsety od začátku souboru a §7.5.5 umisťuje klíčové slovo startxref blízko konce, kde ukazuje na samotnou tabulku. Každé z těchto čísel znehodnotí jakákoli úprava, která posune bajty. FTP session běžící v textovém módu, která překládala CRLF, přerušené stažení, sektor, který se pokazil na sdíleném disku, dávkový nástroj, který připojil data bez správného zápisu přírůstkové aktualizace – to vše ponechá data objektů dokonale čitelná, ale index ukazující do prázdna
Proto je hláška „soubor je poškozený a probíhá jeho oprava“ tak běžná. Bajty jsou téměř vždy pořád tam. Co chybí, je mapa. Rekonstrukce tedy není forenzní obnova ztracených dat, je to přestavba indexu, který lze odvodit z těla souboru, a daří se jí mnohem častěji, než uživatelé čekají, protože nákladný obsah – stromy stránek, fonty i obrázky – zůstává nedotčený
Proč hledání „N 0 obj“ najde falešné shody?
Naivní obnova prohledává surové bajty vzorem „celé číslo, celé číslo, obj“ a zaznamená každý zásah. Najde toho příliš mnoho. PDF je kontejnerový formát a tři oblasti souboru jsou pro gramatiku objektů neprůhledné: komentáře (§7.2), řetězce (§7.3.4) a data streamů (§7.3.8). Kterákoli z nich může obsahovat bajty, které vypadají přesně jako hlavička objektu, a přitom žádná z nich hlavičkou objektu není. Popisek v literálovém řetězci, zapomenutý ladicí komentář nebo dva megabajty výstupu Flate či DCT s klidem vytvoří něco, co vypadá jako 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ý falešný záznam stojí dvakrát. Znečistí obnovenou tabulku číslem objektu, které neexistuje, a může zastínit skutečný objekt se stejným číslem, který se v souboru objevuje později. PDFlibPas proto vůbec nepoužívá porovnávání vzorů. Tokenizuje, což znamená, že vždy ví, zda jsou bajty pod kurzorem kód nebo obsah, a obsah se přeskočí, aniž by byl kdy interpretován
Jednoprůchodový stavový automat nad bloky 64 KiB
PDFlibPas prochází celý soubor přesně jednou, v blocích po 64 KiB, pomocí stavového automatu postaveného na pravidlech pro tokeny z ISO 32000-1 §7.2 a syntaxi nepřímých objektů z §7.3.10. Token končí na bílém znaku nebo na jednom z oddělovacích znaků a hlavička objektu se zaznamená jen tehdy, když byla viděna úplná sekvence kladného čísla objektu, nezáporného čísla generace a holého klíčového slova obj. Zaznamenaný offset je začátek tokenu čísla objektu, protože právě na to musí ukazovat záznam v cross-reference, nikoli na pozici klíč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 detailem je, že stav tokenu a stav řetězce přežijí hranici bloku. Hlavička, která leží přesně na hranici 65536 bajtů, je stále rozpoznána, protože částečný token, čekající pár celých čísel a příznaky „uvnitř řetězce“ se všechny přenášejí do dalšího bloku. Buffery jsou pevné: 64 KiB pro sken, 32 bajtů pro nejdelší token, na kterém může vůbec záležet, a jediná pole, která rostou s velikostí souboru, jsou seznamy čísel objektů, čísel generací a 64bitových offsetů, jejichž velikost je úměrná skutečnému počtu objektů, nikoli velikosti souboru. V praxi sken provádí sekvenční čtení a nejvýše dva explicitní seeky přes celý dokument, což jej dělá životaschopným i pro několikasetmegabajtové vstupy probírané v článku o slučování a dělení s přímým přístupem
Proč se nedá věřit, že stream končí u endstream?
Protože data streamu jsou libovolné bajty a libovolné bajty mohou náhodou vyhláskovat endstream. Stream, který začíná za klíčovým slovem stream, musí být přeskočen jako neprůhledná data, dokud skutečně neskončí, ale první výskyt uzavíracího klíčového slova je jen kandidát. PDFlibPas to řeší vyžadováním potvrzení: token endstream se přijme jako skutečný konec streamu jen tehdy, když je dalším tokenem (mimo bílé znaky) samostatné endobj – sekvence, kterou §7.3.8 vyžaduje kolem stream objektu. Náhodný zásah uvnitř komprimovaných dat téměř nikdy nemá toto pokračování, takže skener zůstává uvnitř streamu a pokračuje dál. Stejně důležitá jsou dvě menší pravidla. Klíčové slovo stream přejde do stavu streamu jen tehdy, když je holým klíčovým slovem, takže objekt jména jako /stream ve slovníku jej nikdy nespustí. A token obj nebo trailer se uzná jen tehdy, když token nepřekročil limit 32 bajtů a nezačínal lomítkem. Bez těchto dvou pojistek by stačil slovník zdrojů se špatnými názvy klíčů, aby vykolejil celý sken, což je přesně ta třída nepřátelského vstupu popsaná v poznámkách o bezpečném parsování nedůvěryhodných PDF
Hledání skutečného konce slovníku traileru
Obnova objektů je jen polovina úkolu, protože loader stále potřebuje trailer, aby našel /Root. PDFlibPas si pamatuje posledních 64 pozic klíčového slova trailer nalezených během skenu a validuje je zpětně, od nejnovější, takže vyhrává nejnovější použitelný trailer a osamocené klíčové slovo, za kterým nenásleduje slovník, prostě neprojde validací a propadne se na předchozího kandidáta. Každý kandidát se čte s limitem 1 MiB a konec slovníku se najde sledováním hloubky vnořených << a >> spolu s escape sekvencemi literálových řetězců, hexadecimálními řetězci a komentáři
// 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
Sledování hloubky není akademická záležitost. Zkrácený trailer, který přijde o /Encrypt, promění obnovitelný šifrovaný dokument v neotevíratelný, a ztráta /Info nebo vlastního podslovníku potichu zahodí metadata, na kterých může záviset navazující systém. Pokud je soubor šifrovaný, právě obnovený trailer umožňuje běh běžné cesty pro přihlašovací údaje, a sémantika opakování je stejná jako ta popsaná v článku o načítání šifrovaných dokumentů
Co vám rekonstrukce nemůže vrátit
Rekonstrukce je snaha o co nejlepší výsledek a být upřímný ohledně jejích limitů je součástí jejího nasazení. Tři případy selžou naprosto. Objekty zabalené uvnitř object streamů (§7.5.7) nejsou pro bajtový sken jednotlivě viditelné, takže pokud kontejner přežije, ale jeho cross-reference stream (§7.5.8) ne, objekty, které drží, nejsou obnovou indexovány. Soubor, jehož tělo bylo skutečně poškozeno, nikoli jen chybně indexováno, vyprodukuje hlavičky, jejichž obsah se už neparsuje. A soubor bez obnovitelného klíčového slova trailer a bez čitelného katalogu nemá nic, k čemu by ukotvil strom dokumentu, bez ohledu na to, kolik hlaviček objektů bylo nalezeno
Duplicitní čísla objektů jsou zajímavým mezipřípadem. Přírůstkově aktualizovaný soubor legitimně obsahuje několik generací stejného čísla objektu a přeživší řetěz cross-reference je jediným záznamem o tom, která z nich byla aktuální. Obnova tento řetěz nemá, takže zaznamená každou hlavičku, kterou vidí, v pořadí souboru, a rozlišuje podle čísla objektu až následně. Obvykle vyhrává pozdější revize, což je obvykle správně, ale dokument, který byl aktualizován a poté částečně vrácen zpět, se může vrátit jemně jinak, než popisoval původní xref. Linearizované soubory nesou stejné upozornění z opačné strany: layout první stránky a hint tabulky ztrácejí smysl, jakmile je index znovu vygenerován, takže opravený soubor by měl být považován za obyčejný, nelinearizovaný dokument
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;
Záložní mechanismus je automatický: PDFlibPas spustí surový sken vždy, když nelze přečíst řetěz cross-reference, a také tehdy, když každý používaný záznam tvrdí offset nula, což je typický příznak tabulky, která byla zapsána, ale nikdy nevyplněna. GetDocumentRepaired vrátí 1, když tato cesta proběhla, a stojí za to to logovat, ne ignorovat, protože dokument, který se načetl přes rekonstrukci, by měl být znovu uložen do čistého souboru, místo aby zůstal v pipeline, jako by se nic nestalo. Uložení zapíše čerstvou, konzistentní tabulku cross-reference, což je nejlevnější možná oprava pro každého navazujícího konzumenta
Cesta rekonstrukce, příznak GetDocumentRepaired a zde ukázaný streamovací loader jsou součástí PDFlibPas Delphi PDF Library, spolu s API pro parsování, vykreslování a podepisování, kterým se věnují jiné články na tomto blogu