Teknisk artikkel

Atomisk PDF-reparasjon i Delphi: rename og DACL-sikkerhet

PDF Library for Delphi publiserer utdataene fra RepairQDFFile gjennom en intern skriver, TPDFQDFFileWriter, som aldri åpner målet for skriving: de reparerte bytene går inn i en eksklusivt opprettet midlertidig fil i samme katalog, filen flushes og lukkes, og først deretter gis den nytt navn over målet med MoveFileExW på Windows eller rename(2) på POSIX. Hvis noe feiler før omdøpingen, beholder målet hver byte det hadde, og kalleren ser LastErrorCode 305. Å reparere et dokument i minnet er den lette halvdelen av en reparasjonsfunksjon. Å få resultatet ned på disk uten å noen gang etterlate brukeren med en tom eller halvskrevet fil, er halvdelen denne artikkelen handler om

Hvorfor kan en reparasjon som feiler fortsatt ødelegge målfilen?

Fordi rekkefølgen på operasjonene var feil. Før v3.539.13 åpnet RepairQDFFile utdataene med PLCreateFileStream(OutputFileName, fmCreate) og ga deretter den strømmen til parseren. fmCreate trunkerer ved åpning, så da QDF-skanningen bestemte at inndataene ikke kunne repareres, var målet allerede tømt. Reparasjon på stedet, der InputFileName og OutputFileName er samme sti, gjorde et avvist inndata til en tapt fil. Parseren selv oppførte seg pent: lavnivåfunksjonen PDFQDFRepair lar målstrømmen være urørt når den avviser tvetydige markører. Den beskyttelsen var rett og slett irrelevant, fordi det offentlige API-et hadde trunkert filen ett kall tidligere

Fiksen i v3.539.13 flyttet reparasjonen inn i en TMemoryStream og åpnet utdataene først etter at PDFQDFRepair hadde lykkes. Det lukker hullet ved parsefeil og ingenting annet. Skrivefasen var fortsatt fmCreate etterfulgt av CopyFrom, så en full disk, en delingskonflikt midtveis eller et unntak mellom trunkeringen og den siste WriteBuffer etterlot fortsatt et ødelagt mål. Reparasjon med minnet først beskytter mot dårlige inndata. Publisering til disk trenger sin egen grense, og v3.539.14 og v3.539.15 bygde en

Hvordan RepairQDFFile i PDF Library for Delphi sluttet å ødelegge sitt eget mål: v3.539.12 åpnet utdataene med PLCreateFileStream og fmCreate, som trunkerer før PDFQDFRepair kan avvise inndataene, v3.539.13 reparerte først inn i en TMemoryStream, og v3.539.15 overlater bytene til TPDFQDFFileWriter for atomisk publisering
Fiksen for parsefeil og fiksen for publisering er to forskjellige grenser: reparasjon med minnet først beskytter mot dårlige inndata, mens skriveren finnes for at en full disk eller en feil midtveis i en skriving ikke lenger skal kunne etterlate målet ødelagt
// v3.539.12: målet trunkeres før inndataene er validert
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
  if PDFQDFRepair(Source, Output, QDFError) then   // for sent å si nei
    Result := 1;
finally
  Output.Free;
end;

// v3.539.15: reparer i minnet, overlat deretter bytene til publiseringsskriveren
Repaired := TMemoryStream.Create;
try
  if not PDFQDFRepair(Source, Repaired, QDFError) then
    Exit;                                          // målet ble aldri åpnet
  Writer := TPDFQDFFileWriter.Create;
  try
    Writer.Save(Repaired, OutputFileName);
    Result := 1;
  finally
    Writer.Free;
  end;
finally
  Repaired.Free;
end;

Hva garanterer atomisk publisering egentlig?

TPDFQDFFileWriter.Save garanterer at målstien enten er den komplette gamle filen eller den komplette nye filen, aldri en blanding, for hver feil biblioteket selv kan observere. Skriveren gjør dette i fire trinn som hvert nekter å fortsette med mindre det forrige ble fullført. Først løser den opp målet med GetFullPathNameW, kaller den to ganger og allokerer bufferen fra den returnerte lengden i stedet for å anta MAX_PATH, slik at lange stier ikke kuttes i stillhet. Deretter oppretter den en midlertidig fil med navnet .pdflib-qdf- pluss en GUID pluss .tmp i målkatalogen, med CreateFileW og CREATE_NEW på Windows og open(2) med O_CREAT or O_EXCL og modus 0600 på POSIX. Begge flaggene gjør at opprettelsen feiler hvis navnet allerede finnes, så to prosesser som kappes om samme GUID, kan ikke dele et håndtak. Tredje trinn kopierer den reparerte strømmen i 64 KiB-biter gjennom WriteBuffer, som reiser et unntak ved en kort skriving i stedet for å returnere et antall ingen sjekker, og kaller deretter FlushFileBuffers eller fsync(2) og lukker håndtaket. Fjerde trinn døper den om

De fire atomiske trinnene i TPDFQDFFileWriter.Save i PDF Library for Delphi: løs opp stien to ganger med GetFullPathNameW, opprett den midlertidige filen .pdflib-qdf med CREATE_NEW eller O_EXCL så kappløpende prosesser ikke kan dele et håndtak, kopier i 64 KiB-biter med WriteBuffer og flush, og bruk deretter MoveFileExW med REPLACE_EXISTING og WRITE_THROUGH
Hvert trinn nekter å fortsette med mindre det forrige ble fullført, den midlertidige filen ligger per konstruksjon på målvolumet, det finnes aldri noe vindu der målet slettes først, og oppryddingen i en finally etterlater ingen .tmp-rester
procedure TPDFQDFFileWriter.Flush(Target: TStream);
begin
  if not FlushFileBuffers(THandleStream(Target).Handle) then
    raise EWriteError.Create('Unable to flush QDF output');
end;

procedure TPDFQDFFileWriter.Publish(const TempFileName, FileName: WideString);
begin
  // Ikke tillat en kopi på tvers av volumer eller sletting av målet først
  if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
    MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
    raise EWriteError.Create('Unable to publish QDF output');
end;

Omdøpingstrinnet er der de fleste hjemmesnekrede «safe save»-rutiner stille bryter sammen. MoveFileExW med MOVEFILE_REPLACE_EXISTING erstatter målet i én filsystemoperasjon på samme volum. Skriveren utelater med vilje MOVEFILE_COPY_ALLOWED, fordi en flytting på tvers av volumer degenererer til kopier-deretter-slett, som er nøyaktig den ikke-atomiske sekvensen hele designet finnes for å unngå. Siden den midlertidige filen ligger i målkatalogen, er den per konstruksjon på målvolumet. Skriveren sletter heller aldri den gamle filen først; et par med slett-deretter-døp har et vindu der stien ikke finnes i det hele tatt, og et krasj inne i det vinduet mister dokumentet. MOVEFILE_WRITE_THROUGH ber kallet om ikke å returnere før omdøpingen har nådd disken, noe som passer sammen med den eksplisitte flushen av dataene. På POSIX garanterer rename(2) allerede at det nye navnet atomisk erstatter en eksisterende fil, og samme plassering i katalogen hindrer at den feiler med EXDEV. Oppryddingen er symmetrisk. Det midlertidige navnet fjernes i en finally-blokk på hver sti, noe som ved suksess er en no-op fordi omdøpingen allerede har konsumert det, og ved feil fjerner den delvise filen så katalogen ikke samler opp .tmp-rester. Regresjonen i Tests\QDFFileRegression.inc sjekker nøyaktig det: etter hver injisert feil samsvarer målbytene med originalen, kildebytene samsvarer med originalen, og katalogen inneholder ingenting annet enn de to fiksturene

Hvorfor løsner en midlertidig fil rettighetene på Windows?

En fil opprettet med en nil-sikkerhetsbeskrivelse arver DACL-en sin fra overordnet katalog, ikke fra filen den skal erstatte. Det er riktig standard for et helt nytt dokument og feil for en reparasjon på stedet. Tenk deg at en operatør har låst contract.pdf til én konto med en beskyttet DACL som ikke arves. En midlertidig fil ved siden av arver katalogens videre rettigheter, og når den døpes om over contract.pdf, bærer den omdøpte filen den vide DACL-en, fordi NTFS-sikkerhet følger filobjektet, ikke navnet. Reparasjonen lykkes, bytene er riktige, og tilgangskontrollen operatøren konfigurerte, er stille borte. Ingenting i returverdien antyder det

PDF Library for Delphi leser derfor målets DACL før den midlertidige filen opprettes og sender den inn som lpSecurityAttributes-argumentet til CreateFileW, så den nye filen fødes med den gamle filens rettigheter og omdøpingen ikke endrer noe operatøren ville merke. Lesningen bruker GetFileSecurityW med DACL_SECURITY_INFORMATION og dimensjonerer bufferen fra det første kallet sitt ERROR_INSUFFICIENT_BUFFER-resultat. Tre forhold gjør at skriveren feiler lukket i stedet for å gjette. Hvis DACL-en ikke kan leses, stopper publiseringen med en EWriteError, som det offentlige API-et mapper til 305. Hvis beskrivelsen kommer tilbake uten at SE_DACL_PRESENT er satt, stopper publiseringen også, fordi å sende en slik beskrivelse til CreateFileW ville la kjernen falle tilbake til prosessens standard-DACL og endre tilgangssemantikken uten at noen ba om det. Og hvis målet bærer FILE_ATTRIBUTE_ENCRYPTED, nekter skriveren blankt: den midlertidige filen ville være klartekst, og å døpe en klartekstfil over en EFS-beskyttet fil publiserer en ukryptert erstatning av noe brukeren valgte å kryptere på filsystemnivå. EFS har ingenting med PDF-standardens sikkerhetshandlere å gjøre, som er temaet i artikkelen om lasting av krypterte dokumenter, men feilmodusen er samme type stille nedgradering

Hvorfor publiseringsskriveren for QDF kopierer mål-DACL-en før den oppretter sin midlertidige fil: en nil-beskrivelse ville arve katalogens videre rettigheter og omdøpingen ville stille utvidet tilgangen, så GetFileSecurityW leser DACL-en, en manglende SE_DACL_PRESENT-bit eller et EFS-attributt stopper publiseringen med 305, og CreateFileW fødes med de gamle rettighetene
NTFS-sikkerhet følger filobjektet, ikke navnet: å sende den leste beskrivelsen som lpSecurityAttributes gjør at omdøpingen ikke endrer noe operatøren har konfigurert, og hver port feiler lukket i stedet for å gjette
Attributes := GetFileAttributesW(PWideChar(Destination));
if Attributes <> INVALID_FILE_ATTRIBUTES then
begin
  if (Attributes and FILE_ATTRIBUTE_ENCRYPTED) <> 0 then
    raise EWriteError.Create('QDF replacement of an EFS encrypted file is not supported');
  // dimensjoner beskrivelsen, og les deretter bare DACL-delen av den
  if not GetFileSecurityW(PWideChar(Destination), DACL_SECURITY_INFORMATION,
    @Security[0], SecuritySize, SecuritySize) then
    raise EWriteError.Create('Unable to read QDF destination permissions');
  if not QDFGetSecurityDescriptorControl(@Security[0], Control, Revision) or
     ((Control and SE_DACL_PRESENT) = 0) then
    raise EWriteError.Create('QDF destination has no explicit DACL');
  SecurityAttributes.lpSecurityDescriptor := @Security[0];
  SecurityPointer := @SecurityAttributes;   // gis til CreateFileW / CREATE_NEW
end;

En detalj fra regresjonen er verdt å huske hvis du skriver en liknende test selv. For å bygge den begrensede fiksturen bruker testen en DACL kun for eieren og må sette SE_DACL_PROTECTED eksplisitt i beskrivelsens kontroll; å bare sende det beskyttede flagget i SecurityInformation-argumentet til SetFileSecurityW gjør ikke en ubeskyttet beskrivelse om til en beskyttet. Påstanden etterpå er at den publiserte filen fortsatt rapporterer den beskyttede biten og en eksplisitt, ikke-null DACL, både for en separat utdatasti og for reparasjon over kildefilen selv

Hvilken LastErrorCode forteller deg hva som feilet?

RepairQDFFile returnerer 1 ved suksess og 0 ved enhver feil, og LastErrorCode sier hvilket trinn som nektet. En kilde som ikke kan leses, inkludert en som en annen prosess holder med en eksklusiv lås, rapporterer 401; lesningen er nå pakket inn slik at et unntak under innlesingen mapper til 401 i stedet for å lekke inn i skrivefeilen. Ugyldig eller tvetydig QDF-struktur, som en duplisert strømmarkør for samme objekt, rapporterer PDFLIB_ERROR_QDF_REPAIR, som er 107, og målet er ikke berørt fordi skriveren aldri ble konstruert. Alt etter reparasjonen, fra oppretting av den midlertidige filen til flush og omdøping, rapporterer PDFLIB_ERROR_QDF_WRITE, som er 305. Regresjonen øver på de realistiske: et mål åpnet av et annet håndtak uten deling for sletting, et skrivebeskyttet mål, en manglende målkatalog, og hvert av de tre skrivertrinnene som feiler gjennom injeksjon. I alle sammen er returen 0, koden er 305, og ingen ny eller delvis målfil finnes etterpå. Den generelle vanen med å lese koden i stedet for bare returverdien, er den samme som beskrives i artikkelen om å diagnostisere stille feil i biblioteket

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    // Reparasjon på stedet: samme sti er både inndata og utdata
    if Pdf.RepairQDFFile('edited.qdf.pdf', 'edited.qdf.pdf') = 1 then
      Log('published; the previous bytes were replaced in one rename')
    else
      case Pdf.LastErrorCode of
        401: Log('could not read the input; it was not modified');
        107: Log('QDF structure rejected; the destination was never opened');
        305: Log('write, flush or replace failed; the destination still holds its old bytes');
      end;
  finally
    Pdf.Free;
  end;
end;

Hvor garantien stopper

Skriveren lover konsistens mot feil prosessen kan se, og den er ærlig om dem den ikke kan se. Hvis prosessen drepes mellom opprettingen av den midlertidige filen og omdøpingen, kjører aldri finally-blokken, og en .pdflib-qdf-<GUID>.tmp-fil blir liggende i katalogen; målet er fortsatt intakt, som er egenskapen som betyr noe, men ryddejobben er din. Strømtap ligger også utenfor løftet: dataene er flushet og omdøpingen er write-through, som er det beste et brukermodusbibliotek kan be om, men skriveren fsynker ikke katalogoppføringen og gir ingen holdbarhetsgaranti utover det filsystemet selv tilbyr. En andre skriver som endrer målet samtidig, oppdages ikke, fordi DACL-en og attributtene leses før den midlertidige filen opprettes og ingenting sjekker dem på nytt ved omdøpingen. Og en vellykket omdøping skaper en ny filidentitet, så alternative datastrømmer og vanlige attributter som arkiv- eller skjult-biten på den gamle filen overlever ikke; bare DACL-en føres med over, med vilje

Den snevrere grensen er hvilket API som i det hele tatt bruker denne stien. Bare RepairQDFFile går gjennom TPDFQDFFileWriter. SaveQDFToFile og ConvertFileToQDF åpner fortsatt utdataene sine med PLCreateFileStream(FileName, fmCreate) og strømmer QDF-konverteringen rett inn i den, på samme måte som den inkrementelle stien beskrevet i artikkelen om å legge til oppdateringer i en strøm skriver til hvilken strøm du enn gir den. De to kallene produserer et nytt feilsøkingsartefakt fra et dokument som allerede er lastet og validert, så hullet ved parsefeil gjaldt aldri for dem, men de arver heller ikke den omdøpingsbaserte publiseringen. Ikke les denne artikkelen som «enhver QDF-eksport er atomisk». Det er én utgang, den hvis inndata er en ikke-betrodd, håndredigert fil og hvis utdata rutinemessig er samme sti, og den kombinasjonen er det som fortjente det ekstra maskineriet. Feilinjeksjonen som beviser alt dette, er billig fordi skriverens tre trinn, WriteData, Flush og Publish, er virtual. Testsubklassen overstyrer ett av dem for å reise et unntak etter at det virkelige arbeidet har startet, kaller Save på en reparert strøm og påstår at unntaket forplanter seg, at kilde- og målbytene er uendret, og at ingen midlertidig fil blir liggende. Ingen global fil-API hektes på, ingen virkelig brukerfil berøres, og de tre trinnene mapper én til én mot de tre måtene en publisering kan feile i produksjon: disken fylles, flushen avvises, eller omdøpingen nektes fordi noen andre holder målet

RepairQDFFile-API-et, den atomiske publiseringsskriveren og resten av feilsøkingsarbeidsflyten for QDF er en del av PDF Library for Delphi, sammen med gjenoppretting av kryssreferanser, inkrementell oppdatering og krypteringsfunksjonene som dekkes andre steder på denne bloggen