Teknisk artikel

Genopbyg beskadigede PDF-xref-tabeller: Delphi recovery-scan

Når en PDF-krydsreferencetabel er ubrugelig, er rettelsen at ignorere den helt og genopbygge den fra filkroppen. PDFlibPas Delphi PDF Library gør dette med en single-pass token-scanner, der registrerer hver ægte indirekte-objekt-header, den ser, og genopretter derefter trailer-dictionaryet og overgiver den rekonstruerede tabel til den normale loader

Hvad går i stykker først, når en PDF er beskadiget

Krydsreferencetabellen er den mest skrøbelige del af en PDF, fordi det er den eneste del, der gemmer absolutte byteoffsets. ISO 32000-1 §7.5.4 definerer de poster som ti-cifrede offsets fra filens start, og §7.5.5 lægger startxref-nøgleordet nær slutningen, pegende på selve tabellen. Hvert af de tal ugyldiggøres af enhver redigering, der forskyder bytes. En FTP-session, der kørte i tekst-tilstand og oversatte CRLF, en afbrudt download, en sektor der gik dårlig på et delt drev, et batch-værktøj der tilføjede uden at skrive en inkrementel opdatering korrekt: alle efterlader objektdataene perfekt læsbare og indekset pegende på skrammel

Det er hvorfor "filen er beskadiget og bliver repareret" er en så almindelig dialog. Bytesne er næsten altid stadig der. Hvad der er væk, er kortet. Rekonstruktion er derfor ikke retsmedicinsk genoprettelse af tabte data, det er en genopbygning af et indeks, der kan udledes fra kroppen, og det lykkes langt oftere end brugere forventer, fordi det dyre indhold, sidetræer og fonte og billeder, er urørt

Hvorfor finder scanning efter N 0 obj falske matches?

En naiv genopbygning søger de rå bytes for mønstret "heltal, heltal, obj" og registrerer hvert hit. Den finder for meget. PDF er et containerformat, og tre regioner af en fil er opake for objekt-grammatikken: kommentarer (§7.2), strenge (§7.3.4) og strøm-data (§7.3.8). Enhver af dem kan indeholde bytes, der læses nøjagtigt som en objekt-header, og ingen af dem er en objekt-header. En billedtekst i en bogstavelig streng, en glemt debug-kommentar, eller to megabyte Flate- eller DCT-output vil alle glad producere noget, der ligner 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;

Hver falsk post koster to gange. Den forurener den genopbyggede tabel med et objektnummer, der ikke findes, og den kan overskygge et rigtigt objekt med samme nummer, der optræder senere i filen. PDFlibPas mønster-matcher derfor slet ikke. Den tokeniserer, hvilket betyder den altid ved, om bytesne under markøren er kode eller payload, og payload springes over uden nogensinde at blive fortolket

En single-pass tilstandsmaskine over 64 KiB-blokke

PDFlibPas scanner hele filen præcis én gang, i 64 KiB-blokke, med en tilstandsmaskine bygget på token-reglerne fra ISO 32000-1 §7.2 og den indirekte-objekt-syntaks fra §7.3.10. Et token slutter ved white space eller ved et af afgrænsningstegnene, og en objekt-header registreres kun, når en komplet sekvens af et positivt objektnummer, et ikke-negativt generationsnummer, og et bart obj-nøgleord er set. Det registrerede offset er starten af objektnummer-tokenet, hvilket er hvad en krydsreference-post skal pege på, ikke positionen af obj-nøgleordet

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;

Den vigtige detalje er, at token-tilstand og streng-tilstand overlever en blokgrænse. En header, der spænder over 65536-byte-linjen, genkendes stadig, fordi det partielle token, det ventende heltalspar, og in-string-flagene alle bæres ind i næste blok. Bufferne er faste: 64 KiB til scanningen, 32 bytes til det længste token, der overhovedet kan betyde noget, og de eneste arrays, der vokser med filen, er objektnummer-, generationsnummer- og 64-bit-offset-listerne, som er proportionale med det reelle objektantal frem for med filstørrelsen. I praksis udsteder scanningen sekventielle læsninger og højst to eksplicitte seeks over hele dokumentet, hvilket er hvad der gør den levedygtig på de flere-hundrede-megabyte-input diskuteret i artiklen om direkte adgang, fletning og splitning

Hvorfor kan en strøm ikke betros at slutte ved endstream?

Fordi strøm-data er vilkårlige bytes, og vilkårlige bytes kan tilfældigvis stave endstream. En strøm, der begynder efter stream-nøgleordet, skal springes over som opake data, indtil den ægte slutter, men den første forekomst af det afsluttende nøgleord er kun en kandidat. PDFlibPas løser dette ved at kræve bekræftelse: et endstream-token accepteres kun som den rigtige slutning af strømmen, når det næste ikke-white-space-token er en enkeltstående endobj, den sekvens §7.3.8 kræver omkring et strøm-objekt. Et tilfældigt hit inde i komprimerede data har næsten aldrig den opfølgning, så scanneren forbliver inde i strømmen og fortsætter. To mindre regler betyder lige så meget. stream-nøgleordet indtræder kun strøm-tilstand, når det er et bart nøgleord, så et navneobjekt såsom /stream i et dictionary udløser det aldrig. Og et obj- eller trailer-token respekteres kun, når tokenet ikke overløb 32-byte-loftet og ikke begyndte med en solidus. Uden de to vagter ville et ressource-dictionary med de forkerte nøglenavne være nok til at afspore scanningen, hvilket er præcis den klasse af fjendtligt input dækket i noterne om at parse ubetroet PDF sikkert

At finde den rigtige slutning af trailer-dictionaryet

At genoprette objekterne er kun halvdelen af jobbet, fordi loaderen stadig har brug for en trailer til at finde /Root. PDFlibPas husker de sidste 64 trailer-nøgleordspositioner fundet under scanningen og validerer dem baglæns, nyeste først, så den nyeste brugbare trailer vinder, og et forvildet nøgleord, der ikke efterfølges af et dictionary, fejler blot valideringen og falder gennem til den forrige kandidat. Hver kandidat læses med et loft på 1 MiB, og dictionaryets slutning lokaliseres ved at spore indlejret <<- og >>-dybde sammen med bogstavelig-streng-escapes, hexadecimale strenge og kommentarer

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

Dybdesporing er ikke akademisk. En afkortet trailer, der mister /Encrypt, gør et genopretteligt krypteret dokument til et, der ikke kan åbnes, og at miste /Info eller et brugerdefineret under-dictionary kasserer stille metadata, et nedstrøms system måske afhænger af. Hvis filen er krypteret, er den genoprettede trailer det, der lader den normale credential-sti køre, og retry-semantikken er den samme som beskrevet i artiklen om indlæsning af krypterede dokumenter

Hvad rekonstruktion ikke kan give dig tilbage

Rekonstruktion er en bedste-indsats, og at være ærlig om dens grænser er en del af at levere den. Tre tilfælde fejler helt. Objekter pakket inde i objektstrømme (§7.5.7) er ikke individuelt synlige for en byte-scanning, så hvis en container overlever, men dens krydsreference-strøm (§7.5.8) ikke gør, indekseres de objekter, den holder, ikke af genopbygningen. En fil, hvis krop faktisk var korrumperet, frem for blot fejlindekseret, vil producere headers, hvis indhold ikke længere parser. Og en fil uden noget genoprettelig trailer-nøgleord og intet læsbart katalog har intet at forankre et dokumenttræ til, uanset hvor mange objekt-headers der blev fundet

Duplikerede objektnumre er det interessante mellemtilfælde. En inkrementelt opdateret fil indeholder legitimt flere generationer af det samme objektnummer, og den overlevende krydsreference-kæde er den eneste registrering af hvilken en, der var aktuel. En genopbygning har ikke den kæde, så den registrerer hver header, den ser, i filrækkefølge og opløser efter objektnummer bagefter. Som regel vinder den senere revision, hvilket som regel er rigtigt, men et dokument, der blev opdateret og så delvist rullet tilbage, kan komme tilbage subtilt anderledes end hvad den originale xref beskrev. Lineariserede filer bærer det samme forbehold fra den anden retning: første-side-layoutet og hint-tabellerne er meningsløse, når først indekset er regenereret, så en repareret fil bør behandles som et almindeligt, ikke-lineariseret 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;

Fallbacken er automatisk: PDFlibPas kører den rå scanning, når som helst krydsreference-kæden ikke kan læses, og også når hver i-brug-post hævder offset nul, hvilket er signaturen på en tabel, der blev skrevet men aldrig udfyldt. GetDocumentRepaired returnerer 1, når den sti kørte, og det er værd at logge frem for at ignorere, fordi et dokument, der blev indlæst gennem rekonstruktion, bør gemmes igen til en ren fil frem for efterladt i en pipeline som om intet skete. At gemme det skriver en frisk, konsistent krydsreferencetabel, hvilket er den billigst mulige rettelse for hver nedstrøms-forbruger

Rekonstruktionsstien, GetDocumentRepaired-flaget og den strømmende loader vist her er del af PDFlibPas Delphi PDF Library, sammen med de parsings-, rendering- og signerings-API'er dækket andre steder på denne blog