Articol tehnic

Reconstruirea tabelelor xref PDF deteriorate în Delphi

Atunci când tabela de referințe încrucișate a unui PDF este inutilizabilă, remediul este să fie ignorată complet și reconstruită din corpul fișierului. PDFlibPas Delphi PDF Library face acest lucru printr-un scaner de token-uri într-o singură trecere, care înregistrează fiecare antet de obiect indirect autentic pe care îl întâlnește, apoi recuperează dicționarul trailer și predă tabela reconstruită loader-ului obișnuit

Ce se strică primul când un PDF este deteriorat

Tabela de referințe încrucișate este cea mai fragilă parte a unui PDF, pentru că este singura parte care stochează deplasamente absolute în octeți. ISO 32000-1 §7.5.4 definește aceste intrări ca deplasamente pe zece cifre față de începutul fișierului, iar §7.5.5 plasează cuvântul-cheie startxref aproape de sfârșit, indicând spre tabelă. Fiecare dintre aceste numere este invalidat de orice editare care deplasează octeți. O sesiune FTP rulată în mod text care a tradus CRLF, o descărcare trunchiată, un sector defect pe un disc partajat, un instrument batch care a adăugat date fără a scrie corect o actualizare incrementală: toate acestea lasă datele obiectelor perfect lizibile, iar indexul indicând spre gunoi

De aceea dialogul „fișierul este deteriorat și este reparat” este atât de frecvent. Octeții sunt aproape întotdeauna încă acolo. Ce lipsește este harta. Reconstrucția nu este, așadar, o recuperare criminalistică a unor date pierdute, ci o refacere a unui index care poate fi dedus din corpul fișierului, și reușește mult mai des decât se așteaptă utilizatorii, pentru că elementele costisitoare — arborii de pagini, fonturile și imaginile — rămân neatinse

De ce scanarea după N 0 obj găsește potriviri false?

O reconstrucție naivă caută în octeții bruți tiparul „întreg, întreg, obj” și înregistrează fiecare potrivire. Găsește prea mult. PDF este un format container, iar trei regiuni ale unui fișier sunt opace pentru gramatica obiectelor: comentariile (§7.2), șirurile de caractere (§7.3.4) și datele de flux (§7.3.8). Oricare dintre ele poate conține octeți care se citesc exact ca un antet de obiect, deși niciuna dintre ele nu este un antet de obiect. O legendă într-un șir literal, un comentariu de depanare rămas din greșeală, sau două megaocteți de ieșire Flate sau DCT vor produce cu ușurință ceva ce arată ca 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;

Fiecare intrare falsă costă dublu. Poluează tabela reconstruită cu un număr de obiect care nu există și poate umbri un obiect real cu același număr, care apare mai târziu în fișier. De aceea PDFlibPas nu face deloc potrivire de tipare. El tokenizează, ceea ce înseamnă că știe mereu dacă octeții de sub cursor sunt cod sau payload, iar payload-ul este sărit fără a fi vreodată interpretat

O mașină de stări într-o singură trecere, pe blocuri de 64 KiB

PDFlibPas scanează întregul fișier exact o dată, în blocuri de 64 KiB, cu o mașină de stări construită pe regulile de token-uri din ISO 32000-1 §7.2 și pe sintaxa obiectelor indirecte din §7.3.10. Un token se termină la spațiu alb sau la unul dintre caracterele delimitator, iar un antet de obiect este înregistrat doar atunci când a fost văzută o secvență completă formată dintr-un număr de obiect pozitiv, un număr de generație nenegativ și un cuvânt-cheie obj de sine stătător. Deplasamentul înregistrat este începutul token-ului numărului de obiect, exact ceea ce trebuie să indice o intrare de referință încrucișată, nu poziția cuvântului-cheie 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;

Detaliul important este că starea token-ului și starea șirului supraviețuiesc peste o graniță de bloc. Un antet care se întinde peste linia de 65536 de octeți este totuși recunoscut, pentru că token-ul parțial, perechea de întregi în așteptare și indicatorii de „în interiorul unui șir” sunt toate transportate în blocul următor. Buferele sunt fixe: 64 KiB pentru scanare, 32 de octeți pentru cel mai lung token care poate conta vreodată, iar singurele tablouri care cresc odată cu fișierul sunt listele de numere de obiect, numere de generație și deplasamente pe 64 de biți, care sunt proporționale cu numărul real de obiecte, nu cu dimensiunea fișierului. În practică, scanarea emite citiri secvențiale și cel mult două seek-uri explicite pe parcursul întregului document, ceea ce o face viabilă pe intrările de sute de megaocteți discutate în articolul despre îmbinarea și divizarea cu acces direct

De ce un flux nu poate fi de încredere că se termină la endstream?

Pentru că datele de flux sunt octeți arbitrari, iar octeții arbitrari pot forma din întâmplare endstream. Un flux care începe după cuvântul-cheie stream trebuie sărit ca date opace până se termină cu adevărat, dar prima apariție a cuvântului-cheie de închidere este doar un candidat. PDFlibPas rezolvă asta cerând o confirmare: un token endstream este acceptat ca sfârșit real al fluxului doar atunci când următorul token care nu este spațiu alb este un endobj de sine stătător, secvența pe care §7.3.8 o cere în jurul unui obiect de tip flux. O potrivire întâmplătoare în interiorul datelor comprimate aproape niciodată nu are acea continuare, așa că scanerul rămâne în interiorul fluxului și continuă. Alte două reguli, mai mici, contează la fel de mult. Cuvântul-cheie stream intră în starea de flux doar atunci când este un cuvânt-cheie de sine stătător, așa că un obiect nume precum /stream dintr-un dicționar nu îl declanșează niciodată. Iar un token obj sau trailer este onorat doar atunci când token-ul nu a depășit limita de 32 de octeți și nu a început cu o bară oblică. Fără aceste două garanții, un dicționar de resurse cu nume de chei greșite ar fi suficient pentru a deraia scanarea, exact clasa de intrare adversarială acoperită în notele despre analiza în siguranță a unui PDF nesigur

Găsirea sfârșitului real al dicționarului trailer

Recuperarea obiectelor este doar jumătate din treabă, pentru că loader-ul are în continuare nevoie de un trailer pentru a găsi /Root. PDFlibPas reține ultimele 64 de poziții ale cuvântului-cheie trailer găsite în timpul scanării și le validează în ordine inversă, de la cea mai recentă, astfel încât cel mai nou trailer utilizabil câștigă, iar un cuvânt-cheie rătăcit care nu este urmat de un dicționar eșuează pur și simplu validarea și trece la candidatul anterior. Fiecare candidat este citit cu o limită de 1 MiB, iar sfârșitul dicționarului este localizat urmărind adâncimea imbricată a perechilor << și >>, împreună cu secvențele de eludare din șirurile literale, șirurile hexazecimale și comentariile

// 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

Urmărirea adâncimii nu este un exercițiu academic. Un trailer trunchiat care pierde /Encrypt transformă un document criptat recuperabil într-unul imposibil de deschis, iar pierderea /Info sau a unui subdicționar personalizat elimină silențios metadate de care un sistem din aval s-ar putea baza. Dacă fișierul este criptat, trailer-ul recuperat este cel care permite rularea traseului normal de acreditare, iar semantica reîncercărilor este aceeași cu cea descrisă în articolul despre încărcarea documentelor criptate

Ce nu îți poate reda reconstrucția

Reconstrucția este un efort de tip best-effort, iar a fi onest despre limitele ei face parte din livrarea acesteia. Trei cazuri eșuează categoric. Obiectele împachetate în fluxuri de obiecte (§7.5.7) nu sunt vizibile individual pentru o scanare pe octeți, așa că, dacă un container supraviețuiește, dar fluxul lui de referințe încrucișate (§7.5.8) nu, obiectele pe care le conține nu sunt indexate de reconstrucție. Un fișier al cărui corp a fost efectiv corupt, nu doar indexat greșit, va produce antete al căror conținut nu se mai poate analiza. Iar un fișier fără niciun cuvânt-cheie trailer recuperabil și fără catalog lizibil nu are de ce să se ancoreze un arbore de document, indiferent de câte antete de obiect au fost găsite

Numerele de obiect duplicate sunt cazul intermediar interesant. Un fișier actualizat incremental conține în mod legitim mai multe generații ale aceluiași număr de obiect, iar lanțul de referințe încrucișate rămas este singura evidență a celui care era curent. O reconstrucție nu are acel lanț, așa că înregistrează fiecare antet pe care îl întâlnește, în ordinea din fișier, și rezolvă ulterior după numărul de obiect. De obicei câștigă revizuirea mai recentă, ceea ce de obicei este corect, dar un document actualizat și apoi parțial revenit la o stare anterioară poate ieși subtil diferit față de ceea ce descria xref-ul original. Fișierele linearizate poartă aceeași avertizare, dar din direcția opusă: aspectul primei pagini și tabelele de indicii nu mai au sens odată ce indexul este regenerat, așa că un fișier reparat ar trebui tratat ca un document simplu, nelinearizat

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;

Alternativa se activează automat: PDFlibPas rulează scanarea brută ori de câte ori lanțul de referințe încrucișate nu poate fi citit, precum și atunci când fiecare intrare în uz pretinde deplasamentul zero, semnătura unei tabele care a fost scrisă, dar niciodată completată. GetDocumentRepaired returnează 1 când acest traseu a rulat, și merită înregistrat în jurnal, nu ignorat, pentru că un document încărcat prin reconstrucție ar trebui resalvat într-un fișier curat, nu lăsat într-un flux de procesare de parcă nimic nu s-ar fi întâmplat. Salvarea lui scrie o tabelă de referințe încrucișate nouă și consistentă, cea mai ieftină remediere posibilă pentru fiecare consumator din aval

Traseul de reconstrucție, indicatorul GetDocumentRepaired și loader-ul de streaming prezentate aici fac parte din PDFlibPas Delphi PDF Library, alături de API-urile de analiză, randare și semnare acoperite în altă parte pe acest blog