Teknisk artikel

Atomar PDF-reparation i Delphi: Rename- og DACL-sikkerhed

PDF Library for Delphi publicerer outputtet af RepairQDFFile gennem en intern writer, TPDFQDFFileWriter, der aldrig åbner destinationen til skrivning: de reparerede bytes ryger i en eksklusivt oprettet temp-fil i samme mappe, filen flushes og lukkes, og først derefter omdøbes den over targetet med MoveFileExW på Windows eller rename(2) på POSIX. Fejler noget før omdøbningen, beholder destinationen hver eneste byte, den havde, og calleren ser LastErrorCode 305. At reparere et dokument i hukommelsen er den lette halvdel af en reparationsfunktion. At få resultatet ud på disken uden nogensinde at efterlade brugeren med en fil på nul bytes eller en halvfærdig fil, er den halvdel, denne artikel handler om

Hvorfor kan en reparation, der fejler, stadig ødelægge target-filen?

Fordi rækkefølgen af operationerne var forkert. Før v3.539.13 åbnede RepairQDFFile outputtet med PLCreateFileStream(OutputFileName, fmCreate) og gav derefter den stream til parseren. fmCreate trunkerer ved åbning, så i det øjeblik QDF-scanningen besluttede, at inputtet ikke kunne repareres, var destinationen allerede tømt. In-place-reparation, hvor InputFileName og OutputFileName er samme sti, forvandlede et afvist input til en mistet fil. Parseren selv opførte sig fint: lavniveaufunktionen PDFQDFRepair lader target-streamen være urørt, når den afviser tvetydige markører. Det værn var simpelthen irrelevant, fordi den offentlige API havde trunkeret filen ét kald tidligere

Fixet i v3.539.13 flyttede reparationen ind i en TMemoryStream og åbnede outputtet først, efter PDFQDFRepair havde lykkedes. Det lukker hullet ved parse-fejl og intet andet. Skrivefasen var stadig fmCreate efterfulgt af CopyFrom, så en disk-full-tilstand, en sharing violation halvvejs eller en exception mellem trunkeringen og den sidste WriteBuffer efterlod stadig en beskadiget destination. Memory-first-reparation værn mod dårligt input. Disk-publicering behøver sin egen grænse, og v3.539.14 og v3.539.15 byggede én

Hvordan RepairQDFFile i PDF Library for Delphi holdt op med at ødelægge sit eget target: v3.539.12 åbnede outputtet med PLCreateFileStream og fmCreate, som trunkerer, før PDFQDFRepair kan afvise inputtet, v3.539.13 reparerede først ind i en TMemoryStream, og v3.539.15 giver bytesene til TPDFQDFFileWriter til atomar publicering
Parse-fejl-fixet og publiceringsfixet er forskellige grænser: memory-first-reparation værn mod dårligt input, mens writeren findes, for at en fuld disk eller et nedbrud halvvejs i en skrivning ikke længere kan efterlade destinationen beskadiget
// v3.539.12: destinationen trunkeres, før inputtet valideres
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
  if PDFQDFRepair(Source, Output, QDFError) then   // for sent at sige nej
    Result := 1;
finally
  Output.Free;
end;

// v3.539.15: reparér i hukommelsen, giv derefter bytesene til publiceringswriteren
Repaired := TMemoryStream.Create;
try
  if not PDFQDFRepair(Source, Repaired, QDFError) then
    Exit;                                          // destinationen aldrig åbnet
  Writer := TPDFQDFFileWriter.Create;
  try
    Writer.Save(Repaired, OutputFileName);
    Result := 1;
  finally
    Writer.Free;
  end;
finally
  Repaired.Free;
end;

Hvad garanterer atomar publicering egentlig?

TPDFQDFFileWriter.Save garanterer, at destinationsstien enten er den komplette gamle fil eller den komplette nye fil, aldrig en blanding, for hver fejl, biblioteket selv kan observere. Writeren gør det i fire trin, som nægter at fortsætte, medmindre det foregående er færdigt. Først resolver den destinationen med GetFullPathNameW, kalder den to gange og allokerer bufferen ud fra den returnerede længde i stedet for at antage MAX_PATH, så lange stier ikke lydløst skæres af. Dernæst opretter den en temp-fil navngivet .pdflib-qdf- plus en GUID plus .tmp i destinationsmappen, med CreateFileW og CREATE_NEW på Windows og open(2) med O_CREAT or O_EXCL og mode 0600 på POSIX. Begge flags får oprettelsen til at fejle, hvis navnet allerede findes, så to processer, der racer om samme GUID, ikke kan dele et handle. Tredje trin kopierer den reparerede stream i 64 KiB-chunks gennem WriteBuffer, som raise'r ved en kort skrivning i stedet for at returnere en count, ingen tjekker, kalder derefter FlushFileBuffers eller fsync(2) og lukker handlet. Fjerde trin omdøber

De fire atomare trin i TPDFQDFFileWriter.Save i PDF Library for Delphi: resolver stien to gange med GetFullPathNameW, opretter .pdflib-qdf-tempfilen med CREATE_NEW eller O_EXCL, så racende processer ikke kan dele et handle, kopierer i 64 KiB WriteBuffer-chunks og flusher, derefter MoveFileExW med REPLACE_EXISTING og WRITE_THROUGH
Hvert trin nægter at fortsætte, medmindre det foregående er færdigt, temp-filen bor på destinationens volumen af konstruktion, et delete-first-vindue findes aldrig, og oprydningen i en finally efterlader intet .tmp-støv
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
  // Tillad ikke en cross-volume-kopi, og slet ikke destinationen 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øbningstrinnet er der, hvor de fleste hjemmelavede "safe save"-rutiner i stilhed fejler. MoveFileExW med MOVEFILE_REPLACE_EXISTING erstatter targetet i én filesystem-operation på samme volumen. Writeren udelader bevidst MOVEFILE_COPY_ALLOWED, for en cross-volume-flytning degraderer til copy-then-delete, hvilket præcis er den ikke-atomare sekvens, hele designet findes for at undgå. Da temp-filen bor i destinationsmappen, er den på destinationens volumen af konstruktion. Writeren sletter heller aldrig den gamle fil først; et delete-then-rename-par har et vindue, hvor stien slet ikke eksisterer, og et crash inde i det vindue mister dokumentet. MOVEFILE_WRITE_THROUGH beder kaldet om ikke at returnere, før omdøbningen har nået disken, hvilket parres med den eksplicitte flush af dataene. På POSIX garanterer rename(2) allerede, at det nye navn atomart erstatter enhver eksisterende fil, og samme mappeplacering holder den fra at fejle med EXDEV. Oprydningen er symmetrisk. Temp-navnet fjernes i en finally-blok på hver vej, hvilket ved succes er en no-op, fordi omdøbningen allerede har indtaget det, og ved fejl fjerner delfilen, så mappen ikke akkumulerer .tmp-støv. Regressionen i Tests\QDFFileRegression.inc tjekker præcis det: efter hver injiceret fejl matcher destinationsbytes originalen, kildebytesene matcher originalen, og mappen indeholder intet ud over de to fixtures

Hvorfor løsner en temp-fil rettighederne på Windows?

En fil oprettet med en nil security descriptor arver sin DACL fra den overordnede mappe, ikke fra filen, den er ved at erstatte. Det er den korrekte default for et helt nyt dokument og den forkerte for en in-place-reparation. Antag, at en operator har låst contract.pdf ned til en enkelt konto med en beskyttet, ikke-arvet DACL. En temp-fil ved siden af arver mappens bredere rettigheder, og når den er omdøbt over contract.pdf, bærer den omdøbte fil den brede DACL, for NTFS-sikkerhed rejser med filobjektet, ikke med navnet. Reparationen lykkes, bytesene er rigtige, og den adgangskontrol, operatoren konfigurerede, er stille væk. Intet i returværdien antyder det

PDF Library for Delphi læser derfor destinationens DACL, før temp-filen oprettes, og giver den med som lpSecurityAttributes-argument til CreateFileW, så den nye fil fødes med den gamle fils rettigheder, og omdøbningen ikke ændrer noget, operatoren ville bemærke. Læsningen bruger GetFileSecurityW med DACL_SECURITY_INFORMATION og størrelsessætter bufferen ud fra første kalls ERROR_INSUFFICIENT_BUFFER-resultat. Tre betingelser får writeren til at fejle lukket i stedet for at gætte. Kan DACL'en ikke læses, stopper publiceringen med en EWriteError, som den offentlige API mapper til 305. Kommer descriptor tilbage uden SE_DACL_PRESENT sat, stopper publiceringen også, for at give sådan en descriptor til CreateFileW ville lade kernen falde tilbage til processens default-DACL og ændre adgangssemantikken, uden at nogen bad om det. Og bærer targetet FILE_ATTRIBUTE_ENCRYPTED, nægter writeren på stedet: temp-filen ville være klartekst, og at omdøbe en klartekstfil over en EFS-beskyttet publicerer en ukrypteret erstatning af noget, brugeren valgte at kryptere på filesystem-niveau. EFS har intet med PDFs standard security handlers at gøre, som er emnet i artiklen om indlæsning af krypterede dokumenter, men fejlmåden er samme slags stille nedgradering

Hvorfor QDF-publiceringswriteren kopierer destinationens DACL, før den opretter sin temp-fil: en nil-descriptor ville arve mappens bredere rettigheder, og omdøbningen ville stille udvide adgangen, så GetFileSecurityW læser DACL'en, en manglende SE_DACL_PRESENT-bit eller en EFS-attribut stopper publiceringen med 305, og CreateFileW fødes med de gamle rettigheder
NTFS-sikkerhed rejser med filobjektet, ikke med navnet: at give den læste descriptor med som lpSecurityAttributes gør, at omdøbningen ikke ændrer noget, operatoren konfigurerede, og hvert værn fejler lukket i stedet for at gætte
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');
  // størrelsessæt descriptor, læs så kun DACL-delen af 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;   // givet videre til CreateFileW / CREATE_NEW
end;

Én detalje fra regressionen er værd at huske, hvis du selv skriver en lignende test. For at bygge den begrænsede fixture anvender testen en owner-only-DACL og skal sætte SE_DACL_PROTECTED i descriptor-control eksplicit; blot at give protected-flaget med i SecurityInformation-argumentet til SetFileSecurityW gør ikke en ubeskyttet descriptor til en beskyttet. Assertionen bagefter er, at den publicerede fil stadig rapporterer protected-bitten og en eksplicit, ikke-null DACL, både for en separat output-sti og for reparation over kildefilen selv

Hvilken LastErrorCode fortæller dig, hvad der fejlede?

RepairQDFFile returnerer 1 ved succes og 0 ved enhver fejl, og LastErrorCode siger, hvilken fase der nægtede. En kilde, der ikke kan læses, inklusive én en anden proces holder med en eksklusiv lås, rapporterer 401; læsningen er nu pakket ind, så en exception under input mapper til 401 i stedet for at sive ud i skrivefejlen. Ugyldig eller tvetydig QDF-struktur, for eksempel en duplikeret stream-markør for det samme objekt, rapporterer PDFLIB_ERROR_QDF_REPAIR, som er 107, og destinationen er ikke blevet rørt, fordi writeren aldrig blev konstrueret. Alt efter reparationen, fra oprettelse af temp-fil gennem flush og omdøbning, rapporterer PDFLIB_ERROR_QDF_WRITE, som er 305. Regressionen øver de realistiske: en destination åbnet af et andet handle uden delete-sharing, en read-only-destination, en manglende destinationsmappe og hver af de tre writer-faser, der fejler gennem injektion. I alle tilfælde er returværdien 0, koden er 305, og der findes ikke noget nyt eller delvist target bagefter. Den generelle vane at læse koden i stedet for kun returværdien er den samme, som er beskrevet i artiklen om at diagnosticere stille fejlen i biblioteket

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    // In-place-reparation: samme sti er input og output
    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

Writeren lover konsistens over for fejle, processen kan se, og er ærlig om dem, den ikke kan. Bliver processen dræbt mellem oprettelsen af temp-filen og omdøbningen, kører finally-blokken aldrig, og der efterlades en .pdflib-qdf-<GUID>.tmp-fil i mappen; destinationen er stadig intakt, hvilket er den egenskab, der tæller, men støvet er dit at feje. Strømtab ligger uden for løftet ligeså: dataene flushes, og omdøbningen er write-through, hvilket er det bedste, et user-mode-bibliotek kan bede om, men writeren laver ikke fsync af directory-entryet og gør intet durability-krav oven på det, filesystemet leverer. En anden writer, der samtidig modificerer destinationen, detekteres ikke, for DACL'en og attributterne læses, før temp-filen oprettes, og intet tjekker dem igen ved omdøbningstidspunktet. Og en succesfuld omdøbning skaber en ny filidentitet, så alternate data streams og almindelige attributter som archive- eller hidden-bitten på den gamle fil overlever ikke; kun DACL'en bæres med bevidst

Den snævrere grænse er, hvilken API overhovedet bruger denne vej. Kun RepairQDFFile går gennem TPDFQDFFileWriter. SaveQDFToFile og ConvertFileToQDF åbner stadig deres output med PLCreateFileStream(FileName, fmCreate) og streamer QDF-konverteringen direkte ind i den, på samme måde som den inkrementelle vej beskrevet i artiklen om at appende updates til en stream skriver til den stream, du giver den. De to kald producerer et nyt debuggingsartefakt ud fra et dokument, der allerede er loadet og valideret, så parse-fejl-hullet gjaldt dem aldrig, men de arver heller ikke den rename-baserede publicering. Læs ikke denne artikel som "enhver QDF-eksport er atomar". Det er én udgang, den, hvis input er en utroværdig, håndredigeret fil, og hvis output rutinemæssigt er samme sti, og den kombination er det, der tjente den maskineriet. Fault-injektionen, der beviser alt dette, er billig, fordi writerens tre faser, WriteData, Flush og Publish, er virtual. Test-subklassen overrider én af dem til at raise'e efter det rigtige arbejde er begyndt, kalder Save på en repareret stream og assert'er, at exceptionen propagaterer, at kilde- og destinationsbytes er uændrede, og at der ikke efterlades nogen temp-fil. Ingen global file-API hækkes, ingen rigtig brugerfil røres, og de tre faser mapper én-til-én på de tre måder, en publicering kan fejle i produktion: disken fyldes, flushen nægtes, eller omdøbningen afvises, fordi en anden holder targetet

RepairQDFFile-API'en, dens atomare publiceringswriter og resten af QDF-debugging-workflowet er en del af PDF Library for Delphi, side om side med cross-reference-recovery, incremental update og krypteringsfunktionerne, der er dækket andre steder på denne blog