Teknisk artikel

Återbygg skadade PDF-xref-tabeller: Delphi-återställning

När en PDF-korsreferenstabell är oanvändbar är lösningen att ignorera den helt och återuppbygga den från filens kropp. PDFlibPas Delphi PDF Library gör detta med en tokenskanner som gör en enda genomgång och registrerar varje äkta indirekt objekthuvud den ser, återställer sedan trailer-ordboken och lämnar den rekonstruerade tabellen till den vanliga inläsaren

Vad går sönder först när en PDF är skadad

Korsreferenstabellen är den mest sköra delen av en PDF, eftersom det är den enda delen som lagrar absoluta byteoffset. ISO 32000-1 §7.5.4 definierar dessa poster som tiosiffriga offset från filens början, och §7.5.5 placerar nyckelordet startxref nära slutet, pekande på själva tabellen. Vart och ett av de talen ogiltigförklaras av varje redigering som flyttar byte. En FTP-session som kördes i textläge och översatte CRLF, en avbruten nedladdning, en sektor som gick sönder på en delad enhet, ett batch-verktyg som lade till data utan att skriva en inkrementell uppdatering korrekt: alla lämnar objektdata fullt läsbara medan indexet pekar på skräp

Det är därför "filen är skadad och håller på att repareras" är en så vanlig dialogruta. Byten finns nästan alltid kvar. Det som saknas är kartan. Rekonstruktion är därför ingen kriminalteknisk återställning av förlorad data, det är en återuppbyggnad av ett index som kan härledas från kroppen, och den lyckas betydligt oftare än användare förväntar sig, eftersom det dyra innehållet — sidträd, teckensnitt och bilder — är orört

Varför hittar en sökning efter N 0 obj falska träffar?

En naiv återuppbyggnad söker i rådata efter mönstret "heltal, heltal, obj" och registrerar varje träff. Den hittar för mycket. PDF är ett containerformat, och tre regioner i en fil är opaka för objektgrammatiken: kommentarer (§7.2), strängar (§7.3.4) och strömdata (§7.3.8). Vilken som helst av dem kan innehålla byte som läses exakt som ett objekthuvud, och ingen av dem är ett objekthuvud. En bildtext i en literal sträng, en kvarglömd felsökningskommentar eller två megabyte Flate- eller DCT-utdata kommer alla glatt att producera något som ser ut som 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;

Varje falsk post kostar dubbelt. Den förorenar den återuppbyggda tabellen med ett objektnummer som inte finns, och den kan skugga ett verkligt objekt med samma nummer som förekommer senare i filen. PDFlibPas mönstermatchar därför inte alls. Den tokeniserar, vilket innebär att den alltid vet om byten under markören är kod eller nyttolast, och nyttolast hoppas över utan att någonsin tolkas

En enda genomgångs tillståndsmaskin över block om 64 KiB

PDFlibPas skannar hela filen exakt en gång, i block om 64 KiB, med en tillståndsmaskin byggd på tokenreglerna i ISO 32000-1 §7.2 och syntaxen för indirekta objekt i §7.3.10. En token avslutas vid blanksteg eller vid ett av avgränsartecknen, och ett objekthuvud registreras endast när en fullständig sekvens av ett positivt objektnummer, ett icke-negativt generationsnummer och ett rent obj-nyckelord har setts. Det registrerade offsetet är starten av objektnummer-tokenen, vilket är det en korsreferenspost måste peka på, inte positionen för obj-nyckelordet

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 viktiga detaljen är att token-tillstånd och strängtillstånd överlever en blockgräns. Ett huvud som spänner över 65536-bytegränsen känns fortfarande igen, eftersom den partiella tokenen, det väntande heltalsparet och i-sträng-flaggorna alla förs vidare till nästa block. Buffertarna är fasta: 64 KiB för skanningen, 32 byte för den längsta token som möjligen kan spela roll, och de enda arrayer som växer med filen är listorna för objektnummer, generationsnummer och 64-bitars offset, vilka är proportionella mot det verkliga objektantalet snarare än mot filstorleken. I praktiken utfärdar skanningen sekventiella läsningar och högst två explicita sökningar (seek) över hela dokumentet, vilket är det som gör den användbar på de flerhundra-megabyte-stora indata som diskuteras i artikeln om direktåtkomst för sammanslagning och delning

Varför kan en ström inte litas på att sluta vid endstream?

Därför att strömdata är godtyckliga byte, och godtyckliga byte kan råka stava endstream. En ström som börjar efter nyckelordet stream måste hoppas över som opak data tills den verkligen slutar, men den första förekomsten av det avslutande nyckelordet är bara en kandidat. PDFlibPas löser detta genom att kräva bekräftelse: en endstream-token accepteras som strömmens verkliga slut endast när nästa token som inte är blanksteg är ett fristående endobj, den sekvens §7.3.8 kräver runt ett strömobjekt. En slumpmässig träff inuti komprimerad data har nästan aldrig den uppföljningen, så skannern stannar kvar i strömmen och fortsätter. Två mindre regler spelar lika stor roll. Nyckelordet stream går bara in i strömtillstånd när det är ett rent nyckelord, så ett namnobjekt som /stream i en ordbok utlöser det aldrig. Och en obj- eller trailer-token respekteras bara när token inte översteg 32-bytetaket och inte började med ett snedstreck. Utan de två skydden skulle en resursordbok med fel nyckelnamn räcka för att spåra ur skanningen, vilket är exakt den typen av fientlig indata som täcks i anteckningarna om säker tolkning av opålitliga PDF-filer

Att hitta trailer-ordbokens verkliga slut

Att återställa objekten är bara halva jobbet, eftersom inläsaren fortfarande behöver en trailer för att hitta /Root. PDFlibPas minns de senaste 64 trailer-nyckelordspositionerna som hittades under skanningen och validerar dem baklänges, senaste först, så att den nyaste användbara trailern vinner och ett förlupet nyckelord som inte följs av en ordbok helt enkelt misslyckas i valideringen och faller igenom till föregående kandidat. Varje kandidat läses med ett tak på 1 MiB, och ordbokens slut lokaliseras genom att spåra nästlat <<- och >>-djup tillsammans med literal strängescaping, hexadecimala strängar och 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

Djupspårning är inte akademisk. En trunkerad trailer som förlorar /Encrypt förvandlar ett återställningsbart krypterat dokument till ett som inte går att öppna, och att förlora /Info eller en anpassad underordbok kasserar tyst metadata som ett nedströms system kan vara beroende av. Om filen är krypterad är den återställda trailern det som låter den normala autentiseringsvägen köras, och återförsökssemantiken är densamma som beskrivs i artikeln om inläsning av krypterade dokument

Vad rekonstruktion inte kan ge dig tillbaka

Rekonstruktion är en bästa-möjliga-ansträngning, och att vara ärlig om dess gränser är en del av att leverera den. Tre fall misslyckas helt. Objekt packade inuti objektströmmar (§7.5.7) är inte individuellt synliga för en byteskanning, så om en behållare överlever men dess korsreferensström (§7.5.8) inte gör det, indexeras inte objekten den innehåller av återuppbyggnaden. En fil vars kropp faktiskt korrumperades, snarare än bara felindexerades, kommer att producera huvuden vars innehåll inte längre går att tolka. Och en fil utan något återställningsbart trailer-nyckelord och ingen läsbar katalog har ingenting att förankra ett dokumentträd i, oavsett hur många objekthuvuden som hittades

Dubbla objektnummer är det intressanta mellanfallet. En inkrementellt uppdaterad fil innehåller legitimt flera generationer av samma objektnummer, och den kvarvarande korsreferenskedjan är den enda uppteckningen av vilken som var aktuell. En återuppbyggnad har inte den kedjan, så den registrerar varje huvud den ser i filordning och löser upp efter objektnummer efteråt. Vanligtvis vinner den senare revisionen, vilket vanligtvis är rätt, men ett dokument som uppdaterades och sedan delvis rullades tillbaka kan komma tillbaka subtilt annorlunda än vad den ursprungliga xref beskrev. Linjäriserade filer bär samma varning från andra hållet: förstasides-layouten och hint-tabellerna blir meningslösa så snart indexet regenereras, så en reparerad fil bör behandlas som ett vanligt, icke-linjäriserat 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;

Reservlösningen är automatisk: PDFlibPas kör rådataskanningen närhelst korsreferenskedjan inte kan läsas, och även när varje post i bruk anger offset noll, vilket är signaturen för en tabell som skrevs men aldrig fylldes i. GetDocumentRepaired returnerar 1 när den vägen kördes, och det är värt att logga snarare än att ignorera, eftersom ett dokument som lästes in via rekonstruktion bör sparas om till en ren fil snarare än lämnas i en pipeline som om ingenting hänt. Att spara det skriver en ny, konsekvent korsreferenstabell, vilket är den billigast möjliga fixen för varje nedströms konsument

Rekonstruktionsvägen, flaggan GetDocumentRepaired och den strömmande inläsaren som visas här är en del av PDFlibPas Delphi PDF Library, tillsammans med API:erna för tolkning, rendering och signering som täcks på andra ställen i den här bloggen