Kada je cross-reference tabela PDF-a neupotrebljiva, popravka je da se ona potpuno ignoriše i ponovo izgradi iz tela fajla. PDFlibPas Delphi PDF Library to radi jednim prolazom token skenera koji beleži svako pravo zaglavlje indirektnog objekta koje vidi, zatim oporavlja rečnik trejlera i prosleđuje rekonstruisanu tabelu običnom loaderu
Šta prvo pukne kad je PDF oštećen
Cross-reference tabela je najkrhkiji deo PDF-a, jer je jedini deo koji čuva apsolutne bajt-ofsete. ISO 32000-1 §7.5.4 definiše te stavke kao desetocifrene ofsete od početka fajla, a §7.5.5 stavlja ključnu reč startxref blizu kraja, gde pokazuje na samu tabelu. Svaki od tih brojeva postaje nevažeći usled bilo koje izmene koja pomera bajtove. FTP sesija koja je radila u tekstualnom modu i prevela CRLF, prekinut download, sektor koji je otkazao na deljenom disku, batch alat koji je dodao sadržaj bez ispravnog pisanja inkrementalnog ažuriranja: svi oni ostavljaju podatke objekata savršeno čitljivim, a indeks kako pokazuje na smeće
Zato je „fajl je oštećen i popravlja se" tako čest dijalog. Bajtovi su gotovo uvek i dalje tu. Ono što nedostaje je mapa. Rekonstrukcija zato nije forenzički oporavak izgubljenih podataka, već ponovna izgradnja indeksa koji se može izvesti iz tela, i uspeva mnogo češće nego što korisnici očekuju, jer skup sadržaj — stabla stranica, fontovi i slike — ostaje netaknut
Zašto skeniranje za N 0 obj nalazi lažna poklapanja?
Naivna rekonstrukcija pretražuje sirove bajtove po šablonu „ceo broj, ceo broj, obj" i beleži svaki pogodak. Nalazi previše. PDF je kontejnerski format, a tri regiona fajla su neprozirna za gramatiku objekata: komentari (§7.2), stringovi (§7.3.4) i podaci tokova (§7.3.8). Bilo koji od njih može sadržati bajtove koji se čitaju tačno kao zaglavlje objekta, a nijedan od njih to nije. Naslov u literal stringu, zaostali debug komentar, ili dva megabajta Flate ili DCT izlaza — svi će rado proizvesti nešto što izgleda kao 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;
Svaka lažna stavka košta dvostruko. Zagađuje rekonstruisanu tabelu brojem objekta koji ne postoji, i može zaseniti pravi objekat sa istim brojem koji se pojavljuje kasnije u fajlu. PDFlibPas zato uopšte ne radi poklapanje po šablonu. Tokenizuje, što znači da uvek zna da li su bajtovi ispod kursora kod ili sadržaj, a sadržaj se preskače bez ikakvog tumačenja
Jedan prolaz kroz automat stanja preko blokova od 64 KiB
PDFlibPas skenira ceo fajl tačno jednom, u blokovima od 64 KiB, sa automatom stanja izgrađenim na pravilima tokena iz ISO 32000-1 §7.2 i sintaksi indirektnog objekta iz §7.3.10. Token se završava na belini ili na jednom od graničnih karaktera, a zaglavlje objekta se beleži samo kada je viđena kompletna sekvenca pozitivnog broja objekta, nenegativnog broja generacije, i gole ključne reči obj. Zabeleženi ofset je početak tokena broja objekta, na šta cross-reference stavka i mora da pokazuje, a ne pozicija ključne reči 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;
Bitan detalj je da stanje tokena i stanje stringa prežive granicu bloka. Zaglavlje koje se proteže preko linije od 65536 bajtova i dalje se prepoznaje, jer parcijalni token, čekajući par celih brojeva i zastavice unutar-stringa svi prelaze u sledeći blok. Baferi su fiksni: 64 KiB za sken, 32 bajta za najduži token koji uopšte može biti bitan, a jedini nizovi koji rastu sa fajlom su liste broja objekta, broja generacije i 64-bitnog ofseta, koje su proporcionalne stvarnom broju objekata, a ne veličini fajla. U praksi sken izdaje sekvencijalna čitanja i najviše dva eksplicitna seek-a preko celog dokumenta, što ga i čini upotrebljivim na inputima od nekoliko stotina megabajta o kojima govori članak o direktnom pristupu pri spajanju i deljenju
Zašto se tok ne sme pouzdati da se završava na endstream?
Zato što su podaci toka proizvoljni bajtovi, a proizvoljni bajtovi mogu slučajno da sriču endstream. Tok koji počinje posle ključne reči stream mora se preskočiti kao neproziran podatak sve dok se zaista ne završi, ali prvo pojavljivanje zatvarajuće ključne reči je samo kandidat. PDFlibPas ovo rešava zahtevajući potvrdu: token endstream prihvata se kao stvarni kraj toka samo kada je sledeći token koji nije belina samostalan endobj, sekvenca koju §7.3.8 zahteva oko stream objekta. Slučajan pogodak unutar kompresovanih podataka gotovo nikad nema taj nastavak, pa skener ostaje unutar toka i nastavlja. Dva manja pravila su podjednako bitna. Ključna reč stream ulazi u stanje toka samo kada je gola ključna reč, tako da name objekat poput /stream u rečniku nikad ne aktivira to stanje. A token obj ili trailer se uvažava samo kada token nije prekoračio ograničenje od 32 bajta i nije počeo kosom crtom. Bez ta dva čuvara, rečnik resursa sa pogrešnim imenima ključeva bio bi dovoljan da izbaci sken sa koloseka, što je tačno klasa neprijateljskog inputa pokrivena u belešci o bezbednom parsiranju nepoverljivih PDF-ova
Pronalaženje pravog kraja rečnika trejlera
Oporavak objekata je samo polovina posla, jer loaderu i dalje treba trejler da bi pronašao /Root. PDFlibPas pamti poslednjih 64 pozicije ključne reči trailer pronađene tokom skena i validira ih unazad, počev od najnovije, tako da najnoviji upotrebljiv trejler pobeđuje, a zalutala ključna reč koju ne prati rečnik jednostavno ne prođe validaciju i pada na prethodnog kandidata. Svaki kandidat se čita sa ograničenjem od 1 MiB, a kraj rečnika se locira praćenjem ugnežđene dubine << i >>, zajedno sa escape sekvencama literal stringova, heksadecimalnim stringovima i komentarima
// 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
Praćenje dubine nije akademsko pitanje. Skraćen trejler koji izgubi /Encrypt pretvara oporavljiv enkriptovan dokument u onaj koji se ne može otvoriti, a gubitak /Info ili prilagođenog podrečnika tiho odbacuje metapodatke od kojih neki sistem dalje u lancu možda zavisi. Ako je fajl enkriptovan, oporavljeni trejler je ono što omogućava normalnu putanju kredencijala da se izvrši, a semantika ponovnih pokušaja je ista ona opisana u članku o učitavanju enkriptovanih dokumenata
Šta rekonstrukcija ne može da vam vrati
Rekonstrukcija je najbolji mogući pokušaj, i biti iskren o njenim granicama deo je isporuke te funkcije. Tri slučaja potpuno propadaju. Objekti upakovani unutar tokova objekata (§7.5.7) nisu pojedinačno vidljivi bajt skeniranju, pa ako kontejner preživi, ali njegov cross-reference tok (§7.5.8) ne preživi, objekti koje sadrži nisu indeksirani rekonstrukcijom. Fajl čije je telo zapravo bilo oštećeno, a ne samo pogrešno indeksirano, proizvešće zaglavlja čiji se sadržaj više ne parsira. A fajl bez oporavljive ključne reči trailer i bez čitljivog kataloga nema na šta da usidri stablo dokumenta, bez obzira koliko je zaglavlja objekata pronađeno
Duplirani brojevi objekata su zanimljiv srednji slučaj. Inkrementalno ažuriran fajl legitimno sadrži nekoliko generacija istog broja objekta, a preživeli cross-reference lanac je jedini zapis o tome koja je trenutno važeća. Rekonstrukcija taj lanac nema, pa beleži svako zaglavlje koje vidi po redosledu u fajlu i naknadno razrešava po broju objekta. Obično kasnija revizija pobeđuje, što je obično ispravno, ali dokument koji je ažuriran, a zatim delimično vraćen unazad, može se vratiti suptilno drugačiji od onoga što je opisivao originalni xref. Linearizovani fajlovi nose istu opasku iz suprotnog smera: raspored prve strane i hint tabele postaju besmisleni čim se indeks regeneriše, pa popravljen fajl treba tretirati kao običan, 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;
Fallback je automatski: PDFlibPas pokreće sirovi sken kad god se cross-reference lanac ne može pročitati, a takođe i kada svaka stavka u upotrebi tvrdi da je ofset nula, što je potpis tabele koja je zapisana, ali nikada popunjena. GetDocumentRepaired vraća 1 kada je ta putanja izvršena, i vredi je logovati, a ne ignorisati, jer dokument koji se učitao kroz rekonstrukciju treba ponovo sačuvati u čist fajl umesto ostaviti u pipelineu kao da se ništa nije desilo. Čuvanje piše svežu, konzistentnu cross-reference tabelu, što je najjeftinija moguća popravka za svakog potrošača dalje u lancu
Putanja rekonstrukcije, zastavica GetDocumentRepaired i streaming loader prikazan ovde deo su PDFlibPas Delphi PDF Library, zajedno sa API-jima za parsiranje, renderovanje i potpisivanje pokrivenim na drugim mestima na ovom blogu